Analytics for MCP servers, in Ruby.
getmcpulse.com · Docs · Dashboard
require "mcpulse"
MCPulse.configure(key: "mp_live_…")
# Around your tool handler:
MCPulse.record("search", arguments, client_name: client) do
my_handler.call(arguments)
endWrapping the handler rather than watching from outside is what lets MCPulse tell
a handler that raised from one that returned an error result — a distinction an
MCP server erases by converting both into isError before anything outside sees
it.
gem "mcpulse"No runtime dependencies. This gem loads into other people's servers, and a dependency that conflicts with what the customer already bundles is a support burden with no upside for a single POST.
| Keyword | Default | Meaning |
|---|---|---|
key: |
— | Ingest key, mp_live_…, minted per MCP in the dashboard |
endpoint: |
https://api.getmcpulse.com |
Point at a local API while developing |
enabled: |
true |
false makes everything a no-op — useful in tests and CI |
debug: |
false |
Log what is sent, and why a send failed, to stderr |
agree: |
off | Fields that more than one tool returns. See below |
agree_window_ms: |
300_000 |
How long a value stays comparable |
agree_tolerance: |
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:
MCPulse.configure(
key: ENV.fetch("MCPULSE_KEY"),
agree: {
"global_liquidity" => [
{ "tool" => "getGlobalLiquidity", "path" => "globalLiquidity.value_t" },
{ "tool" => "getPillars", "path" => "pillars.global_liquidity.value" }
]
},
agree_window_ms: 300_000
)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:
agree_window_ms: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: trueafter 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.
MCPulse.record_startup(tools_list_response, client_name: client)Pass the JSON your tools/list returns — schema_bytes is the cost of a tool's
presence in the context window, so it has to be measured on what actually goes
over the wire.
bad_args is not reported. A server that validates arguments before calling the
handler rejects them outside the block, so the call never reaches record.
Reporting it anyway would mean reading the difference back out of an error
message, and error strings are not an interface anyone promised to keep. ok,
tool_error and crashed are all exact.
Sizes and hashes. Arguments and results do not, and no option turns that on.
- Never raise. Every entry point rescues. Your exception is re-raised untouched; ours never reach you.
- Never block. A
Threadwith its own array rather than a sizedQueue— a boundedQueue#pushblocks when full, which is exactly what must not happen on the path a model is waiting on. This drops the oldest entry instead. - Never store customer data. See above.
args_hash is the first 12 hex characters of the SHA-256 of the
RFC 8785 canonical form of the
arguments. spec/fixtures/canonical.json is the shared conformance suite every
MCPulse SDK runs.
Ruby needed three things undone: Float#to_s writes 1.0 and 1.0e-07 where
ECMAScript writes 1 and 1e-7; JSON.generate leaves keys in insertion
order; and RFC 8785 sorts keys by UTF-16 code unit while Ruby compares UTF-8
bytes — the two disagree above the BMP, where U+1F680 (the surrogate pair D83D
DE80) sorts before U+FFFD.
Symbol-keyed hashes canonicalise identically to string-keyed ones, which matters because that is what most Ruby MCP servers actually hold.
ruby spec/run.rbNo gems needed.
MIT