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();<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.
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.
| 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.
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.
Sizes and hashes. Arguments and results do not, and no option turns that on.
- Never throw. Every entry point swallows. Your exception is re-raised untouched; ours never reach you.
- Never block. Record, buffer, return. Sending happens on a daemon thread.
- Never store customer data. See above.
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.
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.DescribeTestMIT