"We only collect what we need" is a sentence every analytics vendor writes, and it's unfalsifiable. You can't check it, so it carries no information.
This is the falsifiable version: every field the SDK can send, in every payload type, and what each one is for.
There are three payload types. Every one is validated on arrival against a shared wire schema, and anything that doesn't match is dropped. A field that isn't in the schema cannot be stored, because there's nowhere to put it.
1. The call payload
Sent once per tool call, after the handler returns.
{
"v": 1,
"type": "call",
"session_id": "s_7f2a91",
"client_name": "claude-desktop",
"tool_name": "search_orders",
"started_at": "2026-10-07T14:22:31Z",
"duration_ms": 240,
"outcome": "ok",
"response_bytes": 1420,
"is_empty": false,
"args_hash": "9c1b4e2f0a11"
}
| Field | What it is | Why |
|---|---|---|
v |
Wire version | So an old SDK and a new API can tell each other apart |
session_id |
Generated by the SDK | Groups calls so retries and tool pairs can be found. Means nothing outside your own data |
client_name |
From the client's own handshake, e.g. cursor
|
Every metric split by client — a server-wide average hides which client is struggling |
tool_name |
Your tool's name | A metric attached to no tool isn't actionable |
started_at, duration_ms
|
When, and how long | Calls per day, and speed |
outcome |
One of ok, bad_args, tool_error, crashed
|
The four-way split. bad_args means your handler never ran |
response_bytes |
Length of the serialised result | Cost. The length — the result itself is discarded in your process |
is_empty |
True if the result was empty | Succeeded and returned nothing usable. A boolean, decided at the call site |
args_hash |
12 hex characters of a SHA-256 over the arguments, keys sorted | Telling a retry from a repeat. Not the arguments |
Free-text fields are capped — 128 characters for a session or client name, 200 for a tool name. Ingest is a public endpoint, and without caps anyone holding a key could write whatever they liked into your dashboard.
2. The startup payload
Sent once, when the server boots. Same v, session_id and client_name, plus:
| Field | What it is | Why |
|---|---|---|
tools[].name |
Each registered tool | Dead-tool detection — the only way to know a tool exists if nobody calls it |
tools[].schema_bytes |
Length of each tool's serialised schema | Schema cost per session |
tools[].schema_hash |
12-character hash of the schema | Did the interface change between deploys? |
tools[].description_hash |
12-character hash of the description | Did the wording change? |
tools[].description_norm |
Hash of the description after normalising: lowercased, stopwords dropped, stemmed, sorted | Do two tools say the same thing in different words? |
tools[].policy |
Whether a tool declares it needs authorisation or a verified identity | So a tool clients deliberately withhold isn't reported as dead |
sdk, mcp_sdk_version, transport, protocol_version
|
Which MCPulse SDK, which MCP SDK version, stdio or HTTP | How errors reach the model depends on these, not on your code |
Note what's absent: the descriptions and the schemas themselves.
The hashes and the normalisation are computed inside your process precisely so the text never has to leave it. We can tell you a description changed. We cannot tell you what it says.
The two description hashes do different jobs, which is why there are two. The exact hash answers did the bytes move. The normalised one answers did the meaning move — so a cosmetic rewrite doesn't register as churn, and two tools sharing a normalised hash means they converged on the same wording independently.
The version fields are nullable. Several SDKs can't determine the MCP SDK version reliably, and a payload that says "unknown" is more useful than one that guesses.
3. The agreement payload — only if you turn it on
Off by default. If you declare that two tools return the same underlying value, the SDK compares them in your process and sends only the verdict:
{
"v": 1, "type": "agreement", "session_id": "s_7f2a91",
"field": "global_liquidity",
"tool_a": "getGlobalLiquidity", "tool_b": "getPillars",
"agreed": false,
"checked_at": "2026-10-07T14:22:31Z",
"window_ms": 300000
}
Your field's name, the two tool names, and true or false. No value, no difference between the values, and no hash of a value — a hash is just a value that's harder to read.
The comparison has to happen locally for a second reason beyond privacy: 25.22, 25.220 and "25.22" are the same number and three different hashes. Comparing hashes server-side would report a false divergence on every one of them.
At startup the declaration itself is reported as field names and tool names only, never the paths — paths would describe the shape of your results.
What is never sent
Tool arguments. Tool results. Description text. Schema text. Anything about the person using the client — no user ID, no IP stored against a call, no account identifier from your side.
There's no option to send any of them, at any plan.
A guarantee you can switch off is a default, and defaults get changed by whoever is debugging something urgent at 2am — which is exactly the moment nobody is thinking about what ends up in a vendor's database.
How to check this yourself
You don't have to take the post's word for it.
Turn on debug. The SDK logs every payload it sends to stderr — never stdout, which is the transport on a stdio server — so you can read exactly what leaves your process.
Read the schema. The wire format is a single Zod schema shared by the API and every SDK. The API rejects anything outside it.
Read the SDK. The packages are on GitHub. Every language's package is checked against the same conformance fixture, so ten implementations can't drift into sending ten different things.
That's the difference between this list and a privacy policy: every line of it can be verified from your own machine.
If you're building this yourself
The generalisable part isn't the field list. It's the pattern: do the computation where the data already is, so the data never has to move.
Whether a description changed, whether two tools describe themselves alike, whether two tools agree on a number — all of those sound like they need the content. None of them do, if you're running inside the process that already has it.
And the enforcement has to be structural rather than a policy. A schema that rejects unknown fields means the wrong thing can't be stored even by accident. A configuration flag means it can be stored the moment someone flips it.
I build MCPulse — two lines inside your server, never your arguments or your results.
Originally published at getmcpulse.com.
Top comments (0)