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
Preventing tool poisoning: save signatures of possible tool calls #82
One potential benefit of a centralized registry is that we could have server.json submitters list out all the possible tools their server may ever invoke, fingerprint them, and store those fingerpoints for MCP client consumption.
A third party vendor could scan and approve these fingerprints as devoid of security risks, like tool poisoning attacks.
MCP clients could then use the fingerprints to avoid tool poisoning attacks that get surfaced due to hidden dynamic tool calls or supply chain attacks.
Just letting clients do this may make more sense because the client will know at least (2) and (3) or know when they change. But the ecosystem tooling here is still quite immature.
If we focus on the notion of possible tools (i.e. any tool that could ever surface, beyond just at-initialization), those three problems go away, right?
I think tool representation overall, either in the registry spec or MCP metadata, would be useful. Currently clients need to start servers in order to know what tools, prompts, etc. are available. Because MCP servers can now do nice authentication, this can lead to a bunch of login prompts for the user when they first start interacting with their language model. If servers optionally (there might always be servers with nondeterministic tools) published their starting set of tools/prompts, then we could avoid this.
I think tool representation overall, either in the registry spec or MCP metadata, would be useful. Currently clients need to start servers in order to know what tools, prompts, etc. are available.
Agreed, in particular for tool representation in MCP metadata. Unrelated to tool poisoning, but it might allow clients to statically index tools for client-side tool search.
We are facing a challenge that would also be solved by having easy access to the tools provided by each MCP server. In our case, we want to avoid forcing a user to authenticate to a service before we can even confirm it has the tools to solve their problem (via tools/list).
Could the server schema within the registry API be augmented to include tools? This would allow a server operator to choose to list their tool schemas publicly as part of the discovery process.
There are several benefits to this, from improved user experience for cases like ours, to more accurate MCP server selection in case there is more than one for a particular service, not to mention improved transparency within the ecosystem.
A nuanced “possible tools” angle makes sense considering dynamic cases as @connor4312 pointed out. However, while we want to support all cases, we do want to optimize for the common case. And the majority of MCP servers does have a static list of tools.
Happy to contribute further details on our use case or to take part in the discussion on the details.
Include examples in the adjacent files, update any code this impacts
For the first update, would expect to see a thoughtful canvassing of any other precedent out there for this feature. For example, Anthropic's DXT format is probably good precedent for this / maybe we should align with their shape for tools. And the team there would probably have an opinion on this as well cc @felixrieseberg
Would we rely on the author of the MCP server to populate the list of tools? If so should there be a way to prove/validate these upon publishing? What I mean is this raises a concern that bad actors can populate this list intentionally in a wrongful way which may cause malicious results further down. I'm supportive of having the tool's list in advance, just raising that it would be nice if there's a way to guarantee its correctness upon publishing and/or consuming.
I don't think we can pursue running servers and introspecting them - there is too much of a rabbit hole there. What do you do for remote servers? What do you do for auth gates? What do you do for dynamic lists that won't be present on initial startup? Not to mention the quagmire that is trying to get them to run at all in a unified way across all the possible package registries.
We should think through what exposure there might be and give guidance on "how consumers should use this data" accordingly. At the end of the day, I only expect consumers to use this data as a kind of search/filtering/documentation mechanism; it shouldn't be "run" in any way, so not even prompt injection should be a concern here (perhaps we should give guidance to not pull this data into inference for any reason).
@goncalossilva can you share more about the nature of your intended use case to help guide this level of trust factor?
Thanks for these! I'm happy to push these forward soon.
can you share more about the nature of your intended use case to help guide this level of trust factor?
We're exploring using LLMs to convert prompts into workflows with minimal manual work from the user. The upcoming MCP registry is a great start to help us discover available MCP servers we might want to use, but that's generally not enough. We also need to know which capabilities are available in each MCP server.
We could do this back and forth of first identifying relevant MCP servers, then handling authentication, and only then listing tools and preparing the workflow. And for now, we plan to do exactly that. But we're concerned about the use case where it's only after all of that, including effort from the user themself, that we realize we don't have the right tools to carry out the job. Determining that before the user puts in any effort would be much better UX.
Does this help?
For what it's worth, I agree that having to prove the list of tools is complex and I wouldn't consider it for a first version. Trust is paramount, but this documentation would be authored by the MCP server authors themselves. Potentially even automated via the SDK, reducing the risk of even unintentional errors.
Potentially even automated via the SDK, reducing the risk of even unintentional errors.
Curious what exactly you mean by this?
This is something I still have to explore, but seeing how users of MCP's SDKs annotate their tools (e.g., @mcp.prompt(title="Do something") in Python) it may be possible to automate creating the “manifest” of tools for the registry, so there is less manual work involved, and consequently, it becomes less error-prone.
Thank you for jumping in on this!
My pleasure! Let's see where this goes. I'm off next week but planning to take some of the steps above after I'm back.
Pre-registration security scanning would go a long way here. Every server submitted to the registry could be scanned for OWASP MCP Top 10 issues before listing.
We built cybersecify for exactly this — 8 security tools that can scan any MCP server for auth bypass, command injection, SSRF, tool poisoning, rug pulls, unsigned messages, replay attacks, and rate limiting.
A registry integration could work like:
Server submitted for listing
Automated scan_server runs the OWASP MCP Top 10 checks
assess_risk scores every exposed tool
Results displayed as a security badge on the listing
We also have DVMCP as a test target — deliberately vulnerable, 10 intentional issues, useful for validating any scanning pipeline.
Registry fingerprints and SEP-1649 answer "what did this server declare when it was published".
There's a second half that a registry structurally cannot cover: what the server served you, in this session.
A server can pass pre-registration scanning and still hand a specific client a different tool
definition at tools/list time — different description, different schema, different annotations.
The registry never sees that exchange, so a publication-time fingerprint can't witness it. That is
also the shape of the rug pull: the definition that gets approved and the definition that is later
served are not the same object.
The client-side half is cheap and needs no registry cooperation: digest each tool's name,
description, input schema and annotations at listing time, keep a manifest digest of the set, and
compare on every subsequent listing. A changed definition is then detectable in-session, and if the
digests go into a hash-linked record, provable afterwards. The two compose — a registry fingerprint
gives you an expected value, and the client-side digest tells you whether what arrived matches it.
On @DSHCorrectover's nested field injection: computing the digest over an extracted, fixed field
set rather than over the raw JSON-RPC response closes that class by construction. Fields injected
at any nesting depth are simply not inputs to the digest, so they cannot alter or forge it. It costs
you detection of the injected fields themselves, which is a separate check, but the evidence value
of the digest survives a compromised upstream.
I've implemented the client-side half in infy (Apache-2.0) as a
wrapper around mcp.ClientSession, tested end-to-end against a live server including a real
mid-session description rewrite. Happy to write it up as a client-side recommendation here if that's
useful to the SEP-1649 discussion.
One related thing worth stating in this thread regardless: ToolAnnotations are unverified server
claims, and the SDK says so — "clients should never make tool use decisions based on ToolAnnotations
received from untrusted servers." If annotations get carried into registry manifests for
discovery-without-connecting, that caveat needs to travel with them, or a readOnlyHint becomes a
trust signal purely by virtue of being in a registry.
Building on the last comment: there's a third thing between those two, which is what each release of a server declared, over time.
I run smallprint.dev, which reads the published tool definitions of the servers in this registry (plus npm and PyPI) every night and keeps a hash per version over every tool's name, description and input schema. That way you can see when a description changed between releases, and diff it.
On the client side, npx smallprint check --locked compares what's installed on your machine against a lock you took earlier, and fails when a tool changed.
It doesn't cover the per-session case qy6on describes, a server that serves one client something different at tools/list. That needs a check in the client itself.
Keep the distinction between publisher declarations and third-party observations. The design still needs canonicalization rules, treatment of annotations and dynamic/auth-dependent tools, and client verification semantics; closing the parallel proposal threads does not adopt a format or imply that this has been implemented. Their source links and evidence remain in the linked issues.
The design still needs canonicalization rules, treatment of annotations and dynamic/auth-dependent tools, and client verification semantics
Here's how those came out in practice for smallprint.dev, as one data point.
Canonical form: JSON of [{name, description, inputSchema}] sorted by tool name, with every object key sorted recursively. Prose (the name, the description, and any description or title inside the schema) gets runs of whitespace collapsed to one space and trimmed. Every other string (enum, const, pattern, default) stays byte for byte since whitespace can mean something there, and required arrays get sorted because their order doesn't. Prose over 20,000 characters goes in as its opening plus the length and SHA-256 of the whole, so the digest still covers every character. The digest is SHA-256 of that JSON.
Annotations, title and outputSchema aren't in that digest. Whether they belong in a shared format is one of the calls this thread would have to make.
Dynamic and auth-gated tools: an observer like mine only sees what a server lists without credentials. It reads the published package, and where it can't, it starts the package in a container with no network and no keys, so anything that only shows up after sign-in isn't in the digest. The record keeps how each version was read, and I think an observation format should carry that too.
Verification: each version's line goes into a daily row, the rows are hash-chained and signed with Ed25519, and the public key is published. The receipt for a version gives the digest, when it was recorded and the row that holds it, and a short public script recomputes everything from the API. That's the observation time and observer identity #1681 asks for, and a changed or deleted observation would show up as a broken chain.
Following the consolidation from #1720 — contributing working answers to the four open questions, from a spec that's implemented and running (PIN-SPEC, plus a live cross-implementation check below).
Canonicalization: RFC 8785 (JCS) profile — recursive key sort, compact serialization, non-ASCII as raw UTF-8, numbers per JCS §3.2.2.3. The one rule worth naming explicitly: both sides must state their float serialization, because it's unexercised until a schema carries a non-integer number — in a live cross-implementation check (our surfaceHash vs an external observatory's manifest_hash over the same public endpoint's capture, rugsnare#6) the digests agreed byte-for-byte, with the float case explicitly called out as the open edge rather than assumed compatible.
Annotations: store, never hash, compare spec-default-aware — an absent hint equals the spec default, so appearance and disappearance both count as changes. We grade a flipped behavioral hint (readOnlyHint etc.) as its own finding class, separate from schema drift; that separation has held up in practice.
Verification semantics: publisher-attested fingerprints want receipts, not just fields — Ed25519-signed pins, so "the publisher approved this surface" is attributable and tamper-evident in the registry entry itself.
Dynamic/auth-dependent tools: a surface captured under one auth context is a different surface — the fingerprint should name its capture context (unauthenticated prober vs authenticated client), which is precisely the publisher-attested vs externally-observed distinction this thread preserved from #1720. Two hashes on one entry, each labeled with its vantage, joins cleanly later.
On the open question of canonicalization and annotations (rdimitrov's consolidation note), here is a cross-check of three public observers on the same servers, the same day.
Method. On 2026-11-07 between 17:16 and 17:18 UTC, I read tools/list live once from 30 registry remotes (479 tools, all pages, chosen at random from servers that HISTOR, heldfast and CrawlCheck all record), plus CrawlCheck's own server. To those bytes I applied each project's documented digest:
HISTOR MTL/1:{name, description, inputSchema, outputSchema?}, one RFC 8785 array sorted by name in UTF-16 code-unit order, SRI.
heldfast: per tool only, over {name, title, description, input_schema, annotations, output_schema?, icons?} with ""/{} defaults, RFC 8785, hex.
CrawlCheck lockfile v1 (I work on it): {name, description, inputSchema, annotations}, nulls for absent fields; the list hash is the sorted per-tool hashes joined by \n.
Result.
30/30: MTL/1 over my bytes equals HISTOR's published toolSetDigest.
30/30: heldfast's per-tool digest over my bytes equals every tool in its feed state/ file (479/479 tools).
30/30: CrawlCheck's live POST /api/v1/mcp/lock equals its spec.
0/30: any digest can be compared across projects.
So the observations agree. Only the field scope and the list-hash construction differ. Of the 30 servers, 18 send title/annotations, 4 send outputSchema, 8 send execution and 5 send _meta. Any of these moves one project's digest and not another's.
A sample (reproduce from https://histor.modelmarket.dev/api/v1/servers?q=<name> and rufat325/heldfast-feed/state/remote__<name>.json):
What would make these comparable without anyone re-hashing their history:
RFC 8785, SHA-256. Numbers as ECMAScript writes them; keys in UTF-16 code-unit order. A missing name, a number with no double, or a partially paginated list yields no digest (null plus a reason).
hint escalated: readOnlyHint true→false/absent, destructiveHint or openWorldHint →true, absent hints compared as spec defaults;
cosmetic: title or icons only.
Version expectation. A contract_set change or a hint escalation SHOULD come with a new serverInfo.version; observers record version_unchanged: true when it does not. (HISTOR's record of my own server shows seven tool-set changes since 10-10, each at an unchanged serverInfo.version. The version was bumped to 1.2.0 only afterwards. That is how I found this.)
CrawlCheck now publishes this alongside its old hash, without changing the old one: jcs_v1 in lockfiles and in /api/v1/mcp/changes, documented with test vectors at https://crawlcheck.io/spec/mcp-lockfile#jcs-v1. Its value for the server above is sha256-AxcAxfuTrbxxTMqG80WqtRmiGfO4kaoqWJDBCooXSDg=, the same as HISTOR's. The 30-server table and the script are available if anyone wants to re-run them.
Correction to point 2 above, on extras. HISTOR already publishes toolExtrasDigest, and its construction differs from what I wrote. It covers every tool member except name, description, inputSchema and outputSchema (so title, annotations, icons, execution, _meta and vendor fields), as {"tools": {<name>: <extras>}}. CrawlCheck's jcs_v1.extras_sha256 now uses that scope with RFC 8785 and equals HISTOR's published toolExtrasDigest on the same 30 servers: 30/30, including the 10 with no extras. For io.github.emmanuelorta/crawlcheck both are sha256-G44DGgW7+rp9YjMAaEG80Tm5RFoke8eYkrE6nGk69y8=.
Following up on our earlier canonicalization answer: the pin-then-verify loop is now complete end-to-end. As of rugsnare 1.6.0 (on npm today), the Ed25519-signed pin store is checked before the server starts (tampered baseline = boot-time failure), and in enforce mode, individual tools whose contracts drifted are blocked at call time — not just flagged after the fact.
Field data unchanged: 218 silent changes in the npm ecosystem, 140 across reference servers. A fingerprints field in the registry spec would give clients a pre-approval baseline to diff against; we'd adopt it on day one.
On the first two items of the consolidation note, canonicalization rules and annotations: here are the rules written
down, with vectors, and what happened when existing code was run against them.
@emmanuelorta's cross-check above showed that the contract digest of HISTOR and of CrawlCheck's jcs_v1 already agree
on 30 of 30 servers, and named three narrow cases where two encodings can still differ: astral-plane characters in
names, integral floats such as 1.0, and integers beyond 2^53. I wrote that construction down as a short
specification with no project's name in the rules, and vectors for exactly those cases; @emmanuelorta has since joined
as co-editor: https://github.com/alexar76/mcp-tool-set-digest
(mcp-tool-set-digest/1; text CC BY 4.0, code and vectors Apache-2.0 OR MIT).
What is in it:
Two digests, as proposed above. contract covers {name, description, inputSchema, outputSchema?} of every tool,
as one RFC 8785 array sorted by name in UTF-16 code-unit order. extras covers every other member, as {"tools": {name: extras}}. A set in which no tool has extras has no extras digest.
73 vectors, each with the canonical text that is hashed: orderings (Z_tool before a_tool; U+1F600 before U+E000),
number forms (1.0, 1E2, -0.0, 1e21, an integer beyond 2^53, 1e400), every JSON escape, NFC and NFD, lone
surrogates, two tools with one name, wrongly typed members, readOnlyHint flips, and which refusal code wins when
several apply.
Three reference implementations, one file each with no dependencies (Python, JavaScript, Go). They agree on all 73.
What running existing code against the vectors showed:
Contract digest: HISTOR's code and WARDEN's code, both written before this text, agree on 73 of 73.
Extras digest: HISTOR's code agrees on 57 and differs on 16. Each of the 16 is one of the three cases named above, or
one of the storage limits of the deployed form (a 16 KiB cap per tool, U+FFFD for lone surrogates, an entry without a
name passed over, of two tools with one name the last kept). The list is in the repository
(scripts/cross_check_histor.py). The serialiser cases are defects on HISTOR's side; its code can now write the
RFC 8785 form, and its public log still uses the old one.
CrawlCheck's SDKs now implement the text as its own function, apart from the bounded jcs_v1 that signed lockfiles
carry. At commit 7568d46 they give every value of the 70 vectors that predate the three on the order of refusal
codes.
The five values printed in CrawlCheck's specification (empty list, tools_sha256, extras_sha256, per-tool contract and extras of its one-tool example) reproduce.
Settled between the two editors: the storage limits describe how a log stores extras, not the digest, and a value
computed under them is labelled as such; two tools with one name that both carry extras give no extras digest, and the
shared name is reported. Still open: whether instructions and serverInfo need a digest of their own, and under which
field a publisher or the Registry would state the digest it expects. The last one is this tracker's question, and the
text does not try to answer it.
On client verification, one data point: the same comparison now runs as a validator for the interceptor extension
(the response to tools/list), in WARDEN's source tree. It takes the expected digests in config and answers which
tools were added, removed or changed, and whether a change is in the contract or only in extras.
If the vector file is useful here, I am glad to move it wherever the Registry WG prefers, or to add cases.
Disclosure: I maintain HISTOR and WARDEN. This comment was drafted with AI assistance and checked by me.
Confirming from the CrawlCheck side, with one update to the numbers above.
The 70-vector figure was for commit 7568d46, before the three vectors on the order of refusal codes existed. We changed our code to follow section 10 (every entry is checked before any value is written) instead of asking for the text to allow either code. Results against the vectors at aca5812:
A follow-up to the two comments above. Since 2026-11-13 20:03 UTC, HISTOR's public log writes the extras digest as section 8 of mcp-tool-set-digest/1 defines it (RFC 8785); until then it used sorted-key JSON. Of about 186,000 stored extras bodies, 54 got a different digest and 3,334 stored references were rewritten, with no change recorded for any of them. Contract digests were already RFC 8785 and did not move.
A cross-check the same evening on the 30 servers in CrawlCheck's shared list, with CrawlCheck reading each server itself and HISTOR's latest observation (the same day for 28 of them, 10 November for one): contract digests agree on 29 of 29, extras digests on 29 of 29 (20 servers carry extras, 9 none on either side); one server could not be read. On these servers the old encoding gave the same values, so the move matters only where the two encodings differ (54 bodies in HISTOR's log); what changed is that both observers now compute the pair by one written rule, annotations included in the second digest.
Disclosure: I maintain HISTOR. This comment was drafted with AI assistance and checked by me.
A third piece worth covering here: each per-version record could be signed and bound to the artifact digest, so a client can verify it offline instead of trusting the recorder.
To add to that: the per-version digests smallprint keeps are genuinely useful, but a client still has to trust smallprint's service and database. The same is true of any registry-side fingerprint.
What composes with both is making the record verifiable without trusting the recorder: sign it (DSSE/Ed25519, in-toto Statement v1 payload) and bind it to the artifact digest, so anyone can check it offline with the public key. And on re-scan, supersede rather than overwrite, so a verdict flip stays auditable instead of disappearing.
I built and ran that end to end: 20 PyPI MCP servers, each record bound to the exact sdist sha256 it was scanned from, signed, and published with the public key beside it. The records are under docs/scan/ in the lanbass869-cell/canari repository.
Two caveats I want to be explicit about:
the scans in that sample were run by bandit, not by me; each record names the upstream tool and my code only records and signs it
this is artifact-level (bytes), not tool-surface-level, so it is complementary to SEP-1649 rather than a substitute
Activity
connor4312 commentedon May 27, 2025
I had a request on this for VS Code. I didn't dive deep into it but there are a couple scenarios that complicate this.
Just letting clients do this may make more sense because the client will know at least (2) and (3) or know when they change. But the ecosystem tooling here is still quite immature.
tadasant commentedon May 27, 2025
If we focus on the notion of possible tools (i.e. any tool that could ever surface, beyond just at-initialization), those three problems go away, right?
connor4312 commentedon May 27, 2025
Yes. It's a bit of a pain because, given both (2) and (3) happen it's a
NxMmultiplier to the number of tools, but it's possible.connor4312 commentedon May 27, 2025
(though practically only a relatively small number of servers will get localized into a large number of languages)
connor4312 commentedon May 30, 2025
I think tool representation overall, either in the registry spec or MCP metadata, would be useful. Currently clients need to start servers in order to know what tools, prompts, etc. are available. Because MCP servers can now do nice authentication, this can lead to a bunch of login prompts for the user when they first start interacting with their language model. If servers optionally (there might always be servers with nondeterministic tools) published their starting set of tools/prompts, then we could avoid this.
jonathanhefner commentedon May 30, 2025
Agreed, in particular for tool representation in MCP metadata. Unrelated to tool poisoning, but it might allow clients to statically index tools for client-side tool search.
goncalossilva commentedon Jul 25, 2025
Hi everyone, I'm glad that @tadasant raised this.
We are facing a challenge that would also be solved by having easy access to the tools provided by each MCP server. In our case, we want to avoid forcing a user to authenticate to a service before we can even confirm it has the tools to solve their problem (via
tools/list).Could the
serverschema within the registry API be augmented to includetools? This would allow a server operator to choose to list their tool schemas publicly as part of the discovery process.There are several benefits to this, from improved user experience for cases like ours, to more accurate MCP server selection in case there is more than one for a particular service, not to mention improved transparency within the ecosystem.
A nuanced “possible tools” angle makes sense considering dynamic cases as @connor4312 pointed out. However, while we want to support all cases, we do want to optimize for the common case. And the majority of MCP servers does have a static list of tools.
Happy to contribute further details on our use case or to take part in the discussion on the details.
tadasant commentedon Jul 26, 2025
Thanks @goncalossilva ! I think it'd be reasonable for someone to take on adding a field for statically defined tools.
From a guidance perspective, we could say
SHOULD enumerate all possible tools.Steps to contribute:
For the first update, would expect to see a thoughtful canvassing of any other precedent out there for this feature. For example, Anthropic's DXT format is probably good precedent for this / maybe we should align with their shape for tools. And the team there would probably have an opinion on this as well cc @felixrieseberg
rdimitrov commentedon Jul 29, 2025
Would we rely on the author of the MCP server to populate the list of tools? If so should there be a way to prove/validate these upon publishing? What I mean is this raises a concern that bad actors can populate this list intentionally in a wrongful way which may cause malicious results further down. I'm supportive of having the tool's list in advance, just raising that it would be nice if there's a way to guarantee its correctness upon publishing and/or consuming.
tadasant commentedon Jul 29, 2025
I don't think we can pursue running servers and introspecting them - there is too much of a rabbit hole there. What do you do for remote servers? What do you do for auth gates? What do you do for dynamic lists that won't be present on initial startup? Not to mention the quagmire that is trying to get them to run at all in a unified way across all the possible package registries.
We should think through what exposure there might be and give guidance on "how consumers should use this data" accordingly. At the end of the day, I only expect consumers to use this data as a kind of search/filtering/documentation mechanism; it shouldn't be "run" in any way, so not even prompt injection should be a concern here (perhaps we should give guidance to not pull this data into inference for any reason).
@goncalossilva can you share more about the nature of your intended use case to help guide this level of trust factor?
goncalossilva commentedon Jul 30, 2025
Thanks for these! I'm happy to push these forward soon.
We're exploring using LLMs to convert prompts into workflows with minimal manual work from the user. The upcoming MCP registry is a great start to help us discover available MCP servers we might want to use, but that's generally not enough. We also need to know which capabilities are available in each MCP server.
We could do this back and forth of first identifying relevant MCP servers, then handling authentication, and only then listing tools and preparing the workflow. And for now, we plan to do exactly that. But we're concerned about the use case where it's only after all of that, including effort from the user themself, that we realize we don't have the right tools to carry out the job. Determining that before the user puts in any effort would be much better UX.
Does this help?
For what it's worth, I agree that having to prove the list of tools is complex and I wouldn't consider it for a first version. Trust is paramount, but this documentation would be authored by the MCP server authors themselves. Potentially even automated via the SDK, reducing the risk of even unintentional errors.
tadasant commentedon Jul 30, 2025
That makes sense! And aligned with what I was expecting / I stand by my earlier thoughts.
Curious what exactly you mean by this?
Thank you for jumping in on this!
goncalossilva commentedon Jul 31, 2025
This is something I still have to explore, but seeing how users of MCP's SDKs annotate their tools (e.g.,
@mcp.prompt(title="Do something")in Python) it may be possible to automate creating the “manifest” of tools for the registry, so there is less manual work involved, and consequently, it becomes less error-prone.My pleasure! Let's see where this goes. I'm off next week but planning to take some of the steps above after I'm back.
3 remaining items
razashariff commentedon Mar 28, 2026
Pre-registration security scanning would go a long way here. Every server submitted to the registry could be scanned for OWASP MCP Top 10 issues before listing.
We built cybersecify for exactly this — 8 security tools that can scan any MCP server for auth bypass, command injection, SSRF, tool poisoning, rug pulls, unsigned messages, replay attacks, and rate limiting.
A registry integration could work like:
scan_serverruns the OWASP MCP Top 10 checksassess_riskscores every exposed toolWe also have DVMCP as a test target — deliberately vulnerable, 10 intentional issues, useful for validating any scanning pipeline.
npx cybersecify— zero dependencies, works today.DSHCorrectover commentedon Aug 23, 2026
qy6on commentedon Sep 17, 2026
Registry fingerprints and SEP-1649 answer "what did this server declare when it was published".
There's a second half that a registry structurally cannot cover: what the server served you, in
this session.
A server can pass pre-registration scanning and still hand a specific client a different tool
definition at
tools/listtime — different description, different schema, different annotations.The registry never sees that exchange, so a publication-time fingerprint can't witness it. That is
also the shape of the rug pull: the definition that gets approved and the definition that is later
served are not the same object.
The client-side half is cheap and needs no registry cooperation: digest each tool's name,
description, input schema and annotations at listing time, keep a manifest digest of the set, and
compare on every subsequent listing. A changed definition is then detectable in-session, and if the
digests go into a hash-linked record, provable afterwards. The two compose — a registry fingerprint
gives you an expected value, and the client-side digest tells you whether what arrived matches it.
On @DSHCorrectover's nested field injection: computing the digest over an extracted, fixed field
set rather than over the raw JSON-RPC response closes that class by construction. Fields injected
at any nesting depth are simply not inputs to the digest, so they cannot alter or forge it. It costs
you detection of the injected fields themselves, which is a separate check, but the evidence value
of the digest survives a compromised upstream.
I've implemented the client-side half in infy (Apache-2.0) as a
wrapper around
mcp.ClientSession, tested end-to-end against a live server including a realmid-session description rewrite. Happy to write it up as a client-side recommendation here if that's
useful to the SEP-1649 discussion.
One related thing worth stating in this thread regardless:
ToolAnnotationsare unverified serverclaims, and the SDK says so — "clients should never make tool use decisions based on ToolAnnotations
received from untrusted servers." If annotations get carried into registry manifests for
discovery-without-connecting, that caveat needs to travel with them, or a
readOnlyHintbecomes atrust signal purely by virtue of being in a registry.
gostanos commentedon Sep 27, 2026
Building on the last comment: there's a third thing between those two, which is what each release of a server declared, over time.
I run smallprint.dev, which reads the published tool definitions of the servers in this registry (plus npm and PyPI) every night and keeps a hash per version over every tool's name, description and input schema. That way you can see when a description changed between releases, and diff it.
On the client side,
npx smallprint check --lockedcompares what's installed on your machine against a lock you took earlier, and fails when a tool changed.It doesn't cover the per-session case qy6on describes, a server that serves one client something different at
tools/list. That needs a check in the client itself.The per-version digests are public, one server at a time, for example https://smallprint.dev/api/receipt/npm/@ai-sdk/mcp/2.0.49
rdimitrov commentedon Oct 23, 2026
Consolidating two related tool-surface proposals into this tracker:
surfaceHashwith explicit canonicalization and a reference format/implementation.Keep the distinction between publisher declarations and third-party observations. The design still needs canonicalization rules, treatment of annotations and dynamic/auth-dependent tools, and client verification semantics; closing the parallel proposal threads does not adopt a format or imply that this has been implemented. Their source links and evidence remain in the linked issues.
gostanos commentedon Oct 24, 2026
@rdimitrov
Here's how those came out in practice for smallprint.dev, as one data point.
Canonical form: JSON of [{name, description, inputSchema}] sorted by tool name, with every object key sorted recursively. Prose (the name, the description, and any description or title inside the schema) gets runs of whitespace collapsed to one space and trimmed. Every other string (enum, const, pattern, default) stays byte for byte since whitespace can mean something there, and
requiredarrays get sorted because their order doesn't. Prose over 20,000 characters goes in as its opening plus the length and SHA-256 of the whole, so the digest still covers every character. The digest is SHA-256 of that JSON.Annotations, title and outputSchema aren't in that digest. Whether they belong in a shared format is one of the calls this thread would have to make.
Dynamic and auth-gated tools: an observer like mine only sees what a server lists without credentials. It reads the published package, and where it can't, it starts the package in a container with no network and no keys, so anything that only shows up after sign-in isn't in the digest. The record keeps how each version was read, and I think an observation format should carry that too.
Verification: each version's line goes into a daily row, the rows are hash-chained and signed with Ed25519, and the public key is published. The receipt for a version gives the digest, when it was recorded and the row that holds it, and a short public script recomputes everything from the API. That's the observation time and observer identity #1681 asks for, and a changed or deleted observation would show up as a broken chain.
Example, the one from my last comment: https://smallprint.dev/api/receipt/npm/@ai-sdk/mcp/2.0.49 (the script is https://github.com/gostanos/smallprint-action/blob/main/tools/verify-chain.mjs)
Paraphern commentedon Oct 24, 2026
Following the consolidation from #1720 — contributing working answers to the four open questions, from a spec that's implemented and running (PIN-SPEC, plus a live cross-implementation check below).
Canonicalization: RFC 8785 (JCS) profile — recursive key sort, compact serialization, non-ASCII as raw UTF-8, numbers per JCS §3.2.2.3. The one rule worth naming explicitly: both sides must state their float serialization, because it's unexercised until a schema carries a non-integer number — in a live cross-implementation check (our
surfaceHashvs an external observatory'smanifest_hashover the same public endpoint's capture, rugsnare#6) the digests agreed byte-for-byte, with the float case explicitly called out as the open edge rather than assumed compatible.Annotations: store, never hash, compare spec-default-aware — an absent hint equals the spec default, so appearance and disappearance both count as changes. We grade a flipped behavioral hint (
readOnlyHintetc.) as its own finding class, separate from schema drift; that separation has held up in practice.Verification semantics: publisher-attested fingerprints want receipts, not just fields — Ed25519-signed pins, so "the publisher approved this surface" is attributable and tamper-evident in the registry entry itself.
Dynamic/auth-dependent tools: a surface captured under one auth context is a different surface — the fingerprint should name its capture context (unauthenticated prober vs authenticated client), which is precisely the publisher-attested vs externally-observed distinction this thread preserved from #1720. Two hashes on one entry, each labeled with its vantage, joins cleanly later.
emmanuelorta commentedon Nov 7, 2026
On the open question of canonicalization and annotations (rdimitrov's consolidation note), here is a cross-check of three public observers on the same servers, the same day.
Method. On 2026-11-07 between 17:16 and 17:18 UTC, I read
tools/listlive once from 30 registry remotes (479 tools, all pages, chosen at random from servers that HISTOR, heldfast and CrawlCheck all record), plus CrawlCheck's own server. To those bytes I applied each project's documented digest:{name, description, inputSchema, outputSchema?}, one RFC 8785 array sorted by name in UTF-16 code-unit order, SRI.{name, title, description, input_schema, annotations, output_schema?, icons?}with""/{}defaults, RFC 8785, hex.{name, description, inputSchema, annotations}, nulls for absent fields; the list hash is the sorted per-tool hashes joined by\n.Result.
toolSetDigest.state/file (479/479 tools).POST /api/v1/mcp/lockequals its spec.So the observations agree. Only the field scope and the list-hash construction differ. Of the 30 servers, 18 send
title/annotations, 4 sendoutputSchema, 8 sendexecutionand 5 send_meta. Any of these moves one project's digest and not another's.A sample (reproduce from
https://histor.modelmarket.dev/api/v1/servers?q=<name>andrufat325/heldfast-feed/state/remote__<name>.json):sha256-AxcAxfuTrbxxTMqG80WqtRmiGfO4kaoqWJDBCooXSDg=033775e1e9b87637…sha256-V2su5xHuYY2wBDtyEcMHOM0DKKLyydTE37u7lt81O5A=846dcfeea10816ba…sha256-2KGyt6Dq9U9Sv28QPOUa64x0tR5Hi4vjhN1jbW/XEa8=305f725ab2e3fc51…What would make these comparable without anyone re-hashing their history:
contract={name, description, inputSchema, outputSchema?}.extras={title?, annotations?, icons?}._metais in neither.contract_setis exactly MTL/1's tool-set digest. It already matched on 30/30, so adopting it moves no published value.extras_setis the same construction over[{name, extras}].readOnlyHinttrue→false/absent,destructiveHintoropenWorldHint→true, absent hints compared as spec defaults;contract_setchange or a hint escalation SHOULD come with a newserverInfo.version; observers recordversion_unchanged: truewhen it does not. (HISTOR's record of my own server shows seven tool-set changes since 10-10, each at an unchangedserverInfo.version. The version was bumped to 1.2.0 only afterwards. That is how I found this.)after.contract_setcorroborate one another without sharing code.CrawlCheck now publishes this alongside its old hash, without changing the old one:
jcs_v1in lockfiles and in/api/v1/mcp/changes, documented with test vectors at https://crawlcheck.io/spec/mcp-lockfile#jcs-v1. Its value for the server above issha256-AxcAxfuTrbxxTMqG80WqtRmiGfO4kaoqWJDBCooXSDg=, the same as HISTOR's. The 30-server table and the script are available if anyone wants to re-run them.emmanuelorta commentedon Nov 7, 2026
Correction to point 2 above, on
extras. HISTOR already publishestoolExtrasDigest, and its construction differs from what I wrote. It covers every tool member exceptname,description,inputSchemaandoutputSchema(sotitle,annotations,icons,execution,_metaand vendor fields), as{"tools": {<name>: <extras>}}. CrawlCheck'sjcs_v1.extras_sha256now uses that scope with RFC 8785 and equals HISTOR's publishedtoolExtrasDigeston the same 30 servers: 30/30, including the 10 with no extras. For io.github.emmanuelorta/crawlcheck both aresha256-G44DGgW7+rp9YjMAaEG80Tm5RFoke8eYkrE6nGk69y8=.The two encodings can still differ in three narrow cases: astral-plane characters in tool names (sort order), integral floats such as
1.0, and integers beyond 2^53. Worth settling if this becomes the shared definition. Spec and test vector: https://crawlcheck.io/spec/mcp-lockfile#jcs-v1Paraphern commentedon Nov 7, 2026
Following up on our earlier canonicalization answer: the pin-then-verify loop is now complete end-to-end. As of rugsnare 1.6.0 (on npm today), the Ed25519-signed pin store is checked before the server starts (tampered baseline = boot-time failure), and in enforce mode, individual tools whose contracts drifted are blocked at call time — not just flagged after the fact.
Field data unchanged: 218 silent changes in the npm ecosystem, 140 across reference servers. A
fingerprintsfield in the registry spec would give clients a pre-approval baseline to diff against; we'd adopt it on day one.alexar76 commentedon Nov 13, 2026
On the first two items of the consolidation note, canonicalization rules and annotations: here are the rules written
down, with vectors, and what happened when existing code was run against them.
@emmanuelorta's cross-check above showed that the contract digest of HISTOR and of CrawlCheck's
jcs_v1already agreeon 30 of 30 servers, and named three narrow cases where two encodings can still differ: astral-plane characters in
names, integral floats such as
1.0, and integers beyond 2^53. I wrote that construction down as a shortspecification with no project's name in the rules, and vectors for exactly those cases; @emmanuelorta has since joined
as co-editor: https://github.com/alexar76/mcp-tool-set-digest
(
mcp-tool-set-digest/1; text CC BY 4.0, code and vectors Apache-2.0 OR MIT).What is in it:
contractcovers{name, description, inputSchema, outputSchema?}of every tool,as one RFC 8785 array sorted by name in UTF-16 code-unit order.
extrascovers every other member, as{"tools": {name: extras}}. A set in which no tool has extras has no extras digest.Z_toolbeforea_tool; U+1F600 before U+E000),number forms (
1.0,1E2,-0.0,1e21, an integer beyond 2^53,1e400), every JSON escape, NFC and NFD, lonesurrogates, two tools with one name, wrongly typed members,
readOnlyHintflips, and which refusal code wins whenseveral apply.
What running existing code against the vectors showed:
one of the storage limits of the deployed form (a 16 KiB cap per tool, U+FFFD for lone surrogates, an entry without a
name passed over, of two tools with one name the last kept). The list is in the repository
(
scripts/cross_check_histor.py). The serialiser cases are defects on HISTOR's side; its code can now write theRFC 8785 form, and its public log still uses the old one.
jcs_v1that signed lockfilescarry. At commit 7568d46 they give every value of the 70 vectors that predate the three on the order of refusal
codes.
tools_sha256,extras_sha256, per-toolcontractandextrasof its one-tool example) reproduce.Settled between the two editors: the storage limits describe how a log stores extras, not the digest, and a value
computed under them is labelled as such; two tools with one name that both carry extras give no extras digest, and the
shared name is reported. Still open: whether
instructionsandserverInfoneed a digest of their own, and under whichfield a publisher or the Registry would state the digest it expects. The last one is this tracker's question, and the
text does not try to answer it.
On client verification, one data point: the same comparison now runs as a validator for the interceptor extension
(the response to
tools/list), in WARDEN's source tree. It takes the expected digests inconfigand answers whichtools were added, removed or changed, and whether a change is in the contract or only in extras.
If the vector file is useful here, I am glad to move it wherever the Registry WG prefers, or to add cases.
Disclosure: I maintain HISTOR and WARDEN. This comment was drafted with AI assistance and checked by me.
emmanuelorta commentedon Nov 13, 2026
Confirming from the CrawlCheck side, with one update to the numbers above.
The 70-vector figure was for commit
7568d46, before the three vectors on the order of refusal codes existed. We changed our code to follow section 10 (every entry is checked before any value is written) instead of asking for the text to allow either code. Results against the vectors ataca5812:@crawlcheck/sdk1.15.1 (npm)crawlcheck1.16.1 (PyPI)POST /api/v1/mcp/lock)The SDK runs used the packages as installed from the registries. The endpoint figure is its digest function run over the vector file, not 73 live requests. Source: https://github.com/emmanuelorta/crawlcheck-core at
31771a9.Signed lockfiles now carry
tool_set_digest(this text) beside the olderjcs_v1.jcs_v1stays for existing verifiers and is described as a bounded form, as section 8.1 asks: https://crawlcheck.io/spec/mcp-lockfileDisclosure: I maintain CrawlCheck and co-edit the text. This comment was drafted with AI assistance.
alexar76 commentedon Nov 14, 2026
A follow-up to the two comments above. Since 2026-11-13 20:03 UTC, HISTOR's public log writes the extras digest as section 8 of
mcp-tool-set-digest/1defines it (RFC 8785); until then it used sorted-key JSON. Of about 186,000 stored extras bodies, 54 got a different digest and 3,334 stored references were rewritten, with no change recorded for any of them. Contract digests were already RFC 8785 and did not move.A cross-check the same evening on the 30 servers in CrawlCheck's shared list, with CrawlCheck reading each server itself and HISTOR's latest observation (the same day for 28 of them, 10 November for one): contract digests agree on 29 of 29, extras digests on 29 of 29 (20 servers carry extras, 9 none on either side); one server could not be read. On these servers the old encoding gave the same values, so the move matters only where the two encodings differ (54 bodies in HISTOR's log); what changed is that both observers now compute the pair by one written rule, annotations included in the second digest.
Disclosure: I maintain HISTOR. This comment was drafted with AI assistance and checked by me.
lanbass869-cell commentedon Nov 14, 2026
A third piece worth covering here: each per-version record could be signed and bound to the artifact digest, so a client can verify it offline instead of trusting the recorder.
lanbass869-cell commentedon Nov 14, 2026
To add to that: the per-version digests smallprint keeps are genuinely useful, but a client still has to trust smallprint's service and database. The same is true of any registry-side fingerprint.
What composes with both is making the record verifiable without trusting the recorder: sign it (DSSE/Ed25519, in-toto Statement v1 payload) and bind it to the artifact digest, so anyone can check it offline with the public key. And on re-scan, supersede rather than overwrite, so a verdict flip stays auditable instead of disappearing.
I built and ran that end to end: 20 PyPI MCP servers, each record bound to the exact sdist sha256 it was scanned from, signed, and published with the public key beside it. The records are under docs/scan/ in the lanbass869-cell/canari repository.
Two caveats I want to be explicit about: