You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Proposal: expose tool-contract pinning helpers (verify a server's tool surface hasn't drifted between sessions) #1272
Context. Clients built on this SDK typically call listTools once, cache the result, and trust it. If a server's tool contracts change between sessions — description rewrites, schema narrowing, tools appearing or vanishing — the client has no standard way to notice. This isn't hypothetical: an npm-top audit found 218 silent contract changes across the most-installed MCP servers, most inside minor releases (audit), and downstream clients keep re-discovering it (e.g. agentscope-java#3178 for subagent tool inheritance; rowboat is adopting annotation pinning after rowboatlabs/rowboat#1093).
Proposal. Expose a small, dependency-free surface in the SDK so hosts can implement "pin & diff" without inventing semantics:
a canonical hash helper over the tool triple {name, description, inputSchema} (sorted keys, compact serialization, SHA-256) — the field set and canonicalization profile are already specified cross-implementation in PIN-SPEC §3.2, so Java pins would be byte-compatible with pins from other ecosystems;
a ToolContractsChanged event or callback on the client, fired when the live list diverges from the last-seen list (the SDK already handles listChanged and re-requests the list, so both versions are in hand at that moment — classifying added / removed / changed is free).
Not proposing the SDK adopt a specific policy — only the primitives, so hosts can decide what drift means for them. Happy to contribute a PR sketch if there's interest. (For context: we maintain rugsnare, a CLI that does this standalone; this proposal is about the SDK-native primitives, not about the CLI.)
Production-side acceptance cases worth adding before choosing the Java helper's contract boundary (affiliation: I'm contributing from objekts Production Desk / AI-VFX production; this is source review + offline Node tests, not a Java-SDK reproduction):
Output contract: keep the proposed cross-implementation {name,description,inputSchema} hash compatible with PIN-SPEC §3, but please don't make that the only change signal. Adding/changing outputSchema leaves that triple unchanged while changing a result contract a client might parse.
Annotation semantics: in the MCP 2026-07-28 ToolAnnotations schema, missing readOnlyHint has the effective default false, and missing destructiveHint defaults to true. Omitted → explicitly stated default should not be classified as a behavior change; readOnlyHint:true → false should be surfaced. PIN-SPEC already does effective-value comparison separately from its three-field hash. It would be useful for the SDK event to expose effectiveAnnotationChanged separately, without altering the interoperable hash.
Host boundary: adding/removing a tool, changing the declared output, and flipping an effective hint can all be surfaced as diff evidence; the SDK should not treat the server's hint value as a security guarantee. The host still decides whether to pause a tool call pending fresh human approval.
I encoded the first two edge cases in our small offline drift-gate test suite and documented the deliberate limitation. 32/32 Node tests pass in-memory, including a checked-in first-party server.json fixture. The prototype's raw all-fields hash deliberately overreports omission → explicit-default annotations; it is not PIN-SPEC-compatible Java code or a deployed consent gate.
A narrow ToolContractsChanged payload with separate added, removed, surfaceHashChanged, outputSchemaChanged and effectiveAnnotationChanged buckets would let clients preserve interop while avoiding both omissions and false-positive consent churn.
All three cases taken - and they map cleanly onto classes we already ship, which is a good sign for the payload shape:
added / removed are our NEW/GONE findings.
surfaceHashChanged is schema+prose drift - worth carrying a grade in the bucket rather than a bool, so a schema break and a description edit don't cause the same consent reaction (that grading is exactly the anti-churn mechanism your false-positive concern points at).
outputSchemaChanged: agreed, and we ship this as its own hash class already - with directional semantics (output schema appearing = notation, removed = loosened, narrowed = breaking). On whether to fold it into the interop digest: keep the cross-ecosystem digest three-field and carry outputSchema as a separate digest. One ecosystem (MTL/1) folds it into the main digest and that divergence is now documented (comparison); our live cross-vendor pair is byte-compatible precisely on the three-field set.
effectiveAnnotationChanged: your two edge cases are exactly our effective-value comparison rule - omitted-to-explicit-default is a non-event, readOnlyHint:true->false surfaces. Your prototype's deliberate overreporting of omission-to-default is the failure mode that rule exists to prevent; glad the test suite encodes it.
On the host boundary, fully agreed - the server's hint is a claim, the pin makes its flip visible as evidence, and pausing for fresh approval stays the host's decision. That's the same split Shopware is designing against right now (shopware/shopware#22046) and rowboat's contributor adopted (#1093): hints gate nothing until something pins them.
The primitives are the right ask. The audit you cite is also the strongest argument for splitting the hash, because its two largest cases decompose in opposite directions.
chrome-devtools-mcp: 28 tools flagged BREAKING by schema hash, re-graded to zero parameter-level changes. The wave was notation: declared dialect moving from draft-07 to 2020-12, key order, additionalProperties form, SDK-generated integer bounds on timeout/pageSize/pageIdx, plus parameter-description text edits. @azure-devops/mcp is the other shape: 75 tools gone, 29 new, 6 genuinely breaking. One digest fires identically on both, so a host that blocks on any mismatch learns within a week to ignore the digest, which is the same as not having one.
Split it in two over the same snapshot. surface = name, property names, types, required, enum members, additionalProperties shape. contract = the full triple plus title, annotations, outputSchema. A change confined to contract is a warning, a change to surface is a hard stop. That is the NOTATION versus BREAKING regrade the audit had to perform by hand, moved to digest time where it costs nothing.
Canonicalization is where the Java side will break first. Iterating a Map of properties in insertion order gives a different digest for the same schema depending on how the server assembled it, and hashing a schema you deserialized with Jackson is not the same bytes as hashing the raw inputSchema the server sent. That is precisely the chrome-devtools noise class, and a boolean hides it. Sort keys recursively, not only at the top level, normalize number formatting (1.0 versus 1, BigDecimal trailing zeros), and pin the canonical JSON next to the digest so a mismatch prints a diff instead of a verdict.
Diff at connect, not on listChanged. listChanged is optional, most servers never emit it, and a change made while the client is disconnected produces no notification at all, so a notification-driven design covers the smallest part of the window. The usable primitive is a snapshot from the initialize result plus the first listTools page, compared against a pin persisted from the previous session, before the first tool call. That means constructible from a plain ListToolsResult, with no live client and no transport, and diff() returning a typed list (ADDED, REMOVED, SURFACE_CHANGED, CONTRACT_ONLY_CHANGED) rather than a boolean.
One binding detail. Key the pin by (serverInfo.name, serverInfo.version, endpoint), and treat unchanged-version-with-changed-surface as the loudest signal, since that is the shape a supply-chain change takes while a version bump plus a surface change is an ordinary release. Without identity in the key, a pin survives the client being pointed at a different server and the first diff reports a huge false change instead of the truth.
This is the failure we design against in the MCP host at corpusiq.io: a tool whose surface moved under a client returns a well formed wrong answer, with no error anywhere. Pinning is the cheap half of the defence.
Happy to sketch the snapshot and diff classes against the current modelcontextprotocol.modern package.
The surface/contract split is the right decomposition — it's the manual NOTATION/BREAKING regrade we had to do on chrome-devtools (28 first-pass BREAKINGs that decomposed into 24 notation moves and 5 zod integer-bounds artifacts), promoted to digest time. We ship the same idea as schemaHash (structural) vs toolHash (full, includes prose) vs outputHash (response shape) — three digests over the same snapshot, each with its own drift class. The azure-devops case is exactly why one boolean or even two grades aren't enough: 75 tools gone + 29 new + 6 breaking is three different findings in one diff.
On canonicalization: we've been through the Jackson insertion-order problem. Our PIN-SPEC §3 specifies recursive key sort, compact serialization, and RFC 8785 number formatting (1 never 1.0), with two golden test vectors (a simple tool and an integer-key tool that exercises the "10" < "2" ordering) so implementations can byte-check: §3.2. The Java side would need to serialize the raw JsonObject/JsonNode (not deserialized POJOs) with recursive key sort — we'd be happy to contribute the canonical JSON walker as a starting point.
The listChanged gap is real — we solved it in our proxy by re-diffing on every tools/list response the client requests, plus a startup verification of the signed pin store before the server even spawns. The "diff at connect" primitive you describe maps cleanly to a static utility: (previousPin, initialListToolsResult) -> TypedDiff[] — no transport, no live client needed.
Keying by (serverInfo.name, serverInfo.version, endpoint) matches our identity block (PIN-SPEC §2.1 — package, resolvedVersion, entryPath, url, bound by identityHash). The "unchanged version + changed surface" as the loudest signal is exactly what our IDENTITY drift class catches.
Happy to contribute a PR sketch for the snapshot + diff classes alongside yours — two implementations converging on the same primitives is the strongest signal to the maintainers.
Activity
peach420fuzz commentedon Oct 27, 2026
Production-side acceptance cases worth adding before choosing the Java helper's contract boundary (affiliation: I'm contributing from objekts Production Desk / AI-VFX production; this is source review + offline Node tests, not a Java-SDK reproduction):
{name,description,inputSchema}hash compatible with PIN-SPEC §3, but please don't make that the only change signal. Adding/changingoutputSchemaleaves that triple unchanged while changing a result contract a client might parse.ToolAnnotationsschema, missingreadOnlyHinthas the effective defaultfalse, and missingdestructiveHintdefaults totrue. Omitted → explicitly stated default should not be classified as a behavior change;readOnlyHint:true→falseshould be surfaced. PIN-SPEC already does effective-value comparison separately from its three-field hash. It would be useful for the SDK event to exposeeffectiveAnnotationChangedseparately, without altering the interoperable hash.I encoded the first two edge cases in our small offline drift-gate test suite and documented the deliberate limitation. 32/32 Node tests pass in-memory, including a checked-in first-party
server.jsonfixture. The prototype's raw all-fields hash deliberately overreports omission → explicit-default annotations; it is not PIN-SPEC-compatible Java code or a deployed consent gate.A narrow
ToolContractsChangedpayload with separateadded,removed,surfaceHashChanged,outputSchemaChangedandeffectiveAnnotationChangedbuckets would let clients preserve interop while avoiding both omissions and false-positive consent churn.Paraphern commentedon Oct 27, 2026
All three cases taken - and they map cleanly onto classes we already ship, which is a good sign for the payload shape:
added/removedare our NEW/GONE findings.surfaceHashChangedis schema+prose drift - worth carrying a grade in the bucket rather than a bool, so a schema break and a description edit don't cause the same consent reaction (that grading is exactly the anti-churn mechanism your false-positive concern points at).outputSchemaChanged: agreed, and we ship this as its own hash class already - with directional semantics (output schema appearing = notation, removed = loosened, narrowed = breaking). On whether to fold it into the interop digest: keep the cross-ecosystem digest three-field and carryoutputSchemaas a separate digest. One ecosystem (MTL/1) folds it into the main digest and that divergence is now documented (comparison); our live cross-vendor pair is byte-compatible precisely on the three-field set.effectiveAnnotationChanged: your two edge cases are exactly our effective-value comparison rule - omitted-to-explicit-default is a non-event,readOnlyHint:true->falsesurfaces. Your prototype's deliberate overreporting of omission-to-default is the failure mode that rule exists to prevent; glad the test suite encodes it.On the host boundary, fully agreed - the server's hint is a claim, the pin makes its flip visible as evidence, and pausing for fresh approval stays the host's decision. That's the same split Shopware is designing against right now (shopware/shopware#22046) and rowboat's contributor adopted (#1093): hints gate nothing until something pins them.
Kehrlann commentedon Oct 30, 2026
I like the idea of having this capability.
We'll explore in the 2026-07-28 implementation.
media-ctrl commentedon Nov 8, 2026
The primitives are the right ask. The audit you cite is also the strongest argument for splitting the hash, because its two largest cases decompose in opposite directions.
chrome-devtools-mcp: 28 tools flagged BREAKING by schema hash, re-graded to zero parameter-level changes. The wave was notation: declared dialect moving from draft-07 to 2020-12, key order, additionalProperties form, SDK-generated integer bounds on timeout/pageSize/pageIdx, plus parameter-description text edits. @azure-devops/mcp is the other shape: 75 tools gone, 29 new, 6 genuinely breaking. One digest fires identically on both, so a host that blocks on any mismatch learns within a week to ignore the digest, which is the same as not having one.
Split it in two over the same snapshot.
surface= name, property names, types,required, enum members, additionalProperties shape.contract= the full triple plustitle,annotations,outputSchema. A change confined tocontractis a warning, a change tosurfaceis a hard stop. That is the NOTATION versus BREAKING regrade the audit had to perform by hand, moved to digest time where it costs nothing.Canonicalization is where the Java side will break first. Iterating a
Mapof properties in insertion order gives a different digest for the same schema depending on how the server assembled it, and hashing a schema you deserialized with Jackson is not the same bytes as hashing the rawinputSchemathe server sent. That is precisely the chrome-devtools noise class, and a boolean hides it. Sort keys recursively, not only at the top level, normalize number formatting (1.0versus1,BigDecimaltrailing zeros), and pin the canonical JSON next to the digest so a mismatch prints a diff instead of a verdict.Diff at connect, not on
listChanged.listChangedis optional, most servers never emit it, and a change made while the client is disconnected produces no notification at all, so a notification-driven design covers the smallest part of the window. The usable primitive is a snapshot from theinitializeresult plus the firstlistToolspage, compared against a pin persisted from the previous session, before the first tool call. That means constructible from a plainListToolsResult, with no live client and no transport, anddiff()returning a typed list (ADDED, REMOVED, SURFACE_CHANGED, CONTRACT_ONLY_CHANGED) rather than a boolean.One binding detail. Key the pin by
(serverInfo.name, serverInfo.version, endpoint), and treat unchanged-version-with-changed-surface as the loudest signal, since that is the shape a supply-chain change takes while a version bump plus a surface change is an ordinary release. Without identity in the key, a pin survives the client being pointed at a different server and the first diff reports a huge false change instead of the truth.This is the failure we design against in the MCP host at corpusiq.io: a tool whose surface moved under a client returns a well formed wrong answer, with no error anywhere. Pinning is the cheap half of the defence.
Happy to sketch the snapshot and diff classes against the current
modelcontextprotocol.modernpackage.Paraphern commentedon Nov 8, 2026
The surface/contract split is the right decomposition — it's the manual NOTATION/BREAKING regrade we had to do on chrome-devtools (28 first-pass BREAKINGs that decomposed into 24 notation moves and 5 zod integer-bounds artifacts), promoted to digest time. We ship the same idea as
schemaHash(structural) vstoolHash(full, includes prose) vsoutputHash(response shape) — three digests over the same snapshot, each with its own drift class. The azure-devops case is exactly why one boolean or even two grades aren't enough: 75 tools gone + 29 new + 6 breaking is three different findings in one diff.On canonicalization: we've been through the Jackson insertion-order problem. Our PIN-SPEC §3 specifies recursive key sort, compact serialization, and RFC 8785 number formatting (
1never1.0), with two golden test vectors (a simple tool and an integer-key tool that exercises the"10"<"2"ordering) so implementations can byte-check: §3.2. The Java side would need to serialize the rawJsonObject/JsonNode(not deserialized POJOs) with recursive key sort — we'd be happy to contribute the canonical JSON walker as a starting point.The
listChangedgap is real — we solved it in our proxy by re-diffing on everytools/listresponse the client requests, plus a startup verification of the signed pin store before the server even spawns. The "diff at connect" primitive you describe maps cleanly to a static utility:(previousPin, initialListToolsResult) -> TypedDiff[]— no transport, no live client needed.Keying by
(serverInfo.name, serverInfo.version, endpoint)matches our identity block (PIN-SPEC §2.1 — package, resolvedVersion, entryPath, url, bound byidentityHash). The "unchanged version + changed surface" as the loudest signal is exactly what our IDENTITY drift class catches.Happy to contribute a PR sketch for the
snapshot+diffclasses alongside yours — two implementations converging on the same primitives is the strongest signal to the maintainers.Paraphern commentedon Nov 8, 2026
Correction — the golden test vector link in my previous comment used a wrong commit hash. Working link: https://github.com/Paraphern/rugsnare/blob/db0bb6a7021c17f0dd9b26457f65b686a87d24db/docs/PIN-SPEC.md (§3.2, both vectors). Everything else in that comment is unchanged.