Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mcpulse-java

Analytics for MCP servers, for the official Java MCP SDK.

getmcpulse.com · Docs · Dashboard

import com.getmcpulse.MCPulse;
import com.getmcpulse.Options;

var specs = MCPulse.instrument(Options.key("mp_live_…"), searchSpec, fetchSpec);

McpSyncServer server = McpServer.sync(transport)
    .serverInfo("my-server", "1.0.0")
    .tools(specs)
    .build();

Install

<dependency>
  <groupId>com.getmcpulse</groupId>
  <artifactId>mcpulse</artifactId>
  <version>0.1.0</version>
</dependency>

No transitive dependencies. The MCP SDK is provided — you already have it, and pinning a version inside your application is exactly the conflict a library like this must not cause. The JSON reader, the HTTP client and the canonicaliser are all owned here rather than pulled in, for the same reason: nothing this package brings can collide with the Jackson your Spring app has already settled on.

Why the tool specifications and not the server

The Java MCP SDK has no middleware or interceptor chain, and McpSyncServer exposes no way to read back the handler it was given — listTools() returns the declarations, not the callbacks. So there is nothing to wrap after the fact: instrumentation has to happen while the specifications are still in hand.

That is the better place anyway. Wrapping your own callback is what lets MCPulse tell a handler that threw from one that returned an error — a distinction the server erases by converting both into isError before anything outside sees it.

One known gap. The Java SDK validates arguments against the input schema before your callback runs, so a rejected argument set never reaches the wrapper and is not recorded as bad_args. Reporting it would mean reading a difference back out of an error string, and error strings are not an interface anyone promised to keep. ok, tool_error and crashed are all reported exactly.

Options

Method Default Meaning
Options.key(…) Ingest key, mp_live_…, minted per MCP in the dashboard
.endpoint(…) https://api.getmcpulse.com Point at a local API while developing
.enabled(false) enabled Makes instrumentation a no-op — useful in tests and CI
.debug(true) off Log what is sent, and why a send failed, to stderr
.agree(…) off Fields that more than one tool returns. See below
.agreeWindow(…) 5 min How long a value stays comparable
.agreeTolerance(…) 0.0001 Relative, for floats

An empty key turns it off, so a server started without its key configured is silent rather than a source of 401s on every flush.

Cross-tool agreement

Opt-in, and it catches the failure every other metric here calls healthy.

Two tools return the same underlying field. A stale cache key, or a versioned key a cron did not follow, and they disagree for hours. Every number stays green the whole time — the calls succeed, the results are non-empty, there are no retries and the latency is fine. Callers get two different answers to one question and nothing reports it.

Name the value once, and say where each tool returns it:

var specs = MCPulse.instrument(
    Options.key(System.getenv("MCPULSE_KEY"))
        .agree(Map.of("global_liquidity", List.of(
            new Agree.Site("getGlobalLiquidity", "globalLiquidity.value_t"),
            new Agree.Site("getPillars", "pillars.global_liquidity.value")))),
    searchSpec, fetchSpec);

A map rather than a list of field names, because the same value routinely ships under a different name and a different shape in each tool — which is most of why two copies of it drift apart without anyone noticing.

The comparison happens in your process, and only the verdict is sent.

{
  "v": 1, "type": "agreement", "session_id": "s_7f2a91",
  "field": "global_liquidity",
  "tool_a": "getGlobalLiquidity", "tool_b": "getPillars",
  "agreed": false, "checked_at": "2026-09-11T14:22:31Z", "window_ms": 300000
}

No value, no difference, no hash of a value. Hashing could not work anyway: 25.22, 25.220 and "25.22" are the same number and three different hashes, so a server-side check would report every representation change as a divergence forever.

Four things worth knowing before turning it on:

  • .agreeWindow(…) must be shorter than your data's refresh interval. A window that outlives a refresh compares a figure against its own predecessor and calls a legitimate change a divergence.
  • Integers and strings are compared exactly. The tolerance is relative and applies to floats only — a count that is off by one is off by one.
  • Nothing is reported until both tools have been called inside one window. A low-traffic tool can stay silently wrong for a long time, which is a limit of the method rather than a clean result. Run once with .debug(true) after setting it up: a path that never resolves says so there, which is how a typo'd declaration shows up as something other than a passing check.
  • The path is searched in structuredContent, in the JSON of a text content part, and in the result itself, so it does not matter which envelope your tool returns.

What leaves your process

Sizes and hashes. Arguments and results do not, and no option turns that on.

The three rules

  1. Never throw. Every entry point swallows. Your exception is re-raised untouched; ours never reach you.
  2. Never block. Record, buffer, return. Sending happens on a daemon thread.
  3. Never store customer data. See above.

Cross-language consistency

argsHash is the first 12 hex characters of the SHA-256 of the RFC 8785 canonical form of the arguments. src/test/resources/canonical.json is the shared conformance suite every MCPulse SDK runs.

Java needed two things undone and got one for free. Double.toString(1) is "1.0" and Double.toString(1e21) is "1.0E21", neither of which ECMAScript would write — and Java's shortest-round-trip formatting only arrived in JDK 19, so the digits are found by a BigDecimal search that is exact on any JDK. The free part: RFC 8785 sorts keys by UTF-16 code unit, which is what String.compareTo already does. Python and Go both need a workaround there.

Running the tests

No build tool needed:

./fetch-test-deps.sh    # lib/ is gitignored; this pulls the jars
javac -d out -cp "lib/*" $(find src/main/java -name '*.java')
javac -d out -cp "out:lib/*" $(find src/test/java -name '*.java')
java -cp "out:lib/*" com.mcpulse.ConformanceTest
java -cp "out:lib/*" com.mcpulse.IntegrationTest
java -cp "out:lib/*" com.mcpulse.AgreeTest
java -cp "out:lib/*" com.mcpulse.DescribeTest

Licence

MIT

About

Analytics for MCP servers - java SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages