Tool-catalog observed digest: a recomputable preimage for tool-catalog components¶
Status: Draft, informative. Binds nothing. Scope: How the observed_digest of a tool-catalog component is derived from the tools/list a server served, so that a relying party can set expected_observed_digest from bytes it holds and a second implementation can recompute every value from the same bytes. Two counterexamples, digest-mismatch and wrong-subject, with the pinned bytes they recompute from. Target: TR-COMP-MCP-001 in composite-component-appraisal.md, and input to open question 6 there. What an mcp-server component rests on (endpoint identity, transport key binding) and the server-to-catalog relationship method (TR-COMP-MCP-002) stay open. Conformance material: examples/verifier-token-conformance/catalog/ (three pinned catalogs and digests.json), vectors COMP-MCP-004 to COMP-MCP-006, and tests/test_tool_catalog_digest.py, which recomputes every digest from the pinned bytes without the generator.
Requirement keywords are lowercase throughout, on the same line the composite proposal draws: this is informative text and binds no implementation.
1. What a catalog digest is for¶
A tool-catalog component carries an observed_digest, and a relying party may pin an expected_observed_digest in its requirement; a token whose component differs is rejected with component_observation_mismatch. The composite proposal says that much and no more. It does not say what the digest is a digest of, so today the pin is a value the relying party copies from somewhere and cannot check against anything a server actually served.
This document fixes one derivation: from the tools member of a tools/list result to one digest per tool, keyed by tool name, and one digest for the whole catalog. A relying party that holds the tools/list a server served computes the catalog digest itself and pins it; an appraiser that observed the same catalog carries the same digest; a verifier compares two strings it can recompute from bytes. No party needs to call another to obtain the value.
The per-tool digests are the unit that matters at the gateway, where one call is authorized against one definition. The catalog digest is a fold over them and is what the tool-catalog component carries, because the component has one observed_digest.
2. Input¶
The input is the tools member of a tools/list result: a JSON array of tool definitions, as served. The derivation reads the JSON value, so the whitespace, member order and transport framing of the response (JSON body or SSE frame) do not matter. The pinned catalogs under catalog/ are that array, re-serialized with indentation for review and byte-identical in value to what was served.
A definition that RFC 8785 cannot canonicalize (a number outside the IEEE 754 double range, for instance) has no digest under this derivation.
3. Per-tool digest¶
For one tool definition t:
-
Restrict
tto the membersname,title,description,inputSchema,outputSchemaandannotations, in that this is the set the model and client act on. A member that is absent, or present with the valuenull, is omitted. Any other value is kept as is: an empty string, an empty object andfalseare hashed._metaand every member not in the set are never hashed, so a change confined to them is not drift. -
Wrap the restricted object under a profile label:
This document defines the derivation under the trace. label. The label is part of the preimage, carried literally, and versions the derivation: if the member set or any rule here changes, the label changes, and a digest under one label never equals a digest under another. An implementation that adopts this derivation unchanged uses this exact string, or its digests do not recompute. AgentAvow's issuer publishes the same derivation under its own label, with a documented mapping to this one.
-
Canonicalize the wrapped object with RFC 8785 (JCS) and hash the canonical bytes with SHA-256.
-
Write the digest as
sha256:followed by 64 lowercase hexadecimal characters, which is the form the token schema accepts forobserved_digest.
4. Key¶
Each per-tool digest is keyed by the tool's name, encoded so that the key is printable ASCII, cannot contain the separators the fold uses, and is bounded in length:
- Start from the
namemember as a string. A definition with noname, or an empty one, is keyed asunnamed. - Every character outside
0x21to0x7E(space, controls, DEL and every non-ASCII character), plus%and=, is replaced by its UTF-8 bytes, each written as%and two uppercase hexadecimal digits. Every other character is literal. - If the encoded body is longer than 128 characters, it is cut to its first 96 characters and suffixed with
~and the first 16 lowercase hexadecimal characters of SHA-256 over the raw UTF-8 bytes of the original name. The cut is a count of characters in the encoded body, not of source characters, and may fall inside a%XXtriplet; the partial triplet is kept as it falls. - The key is
tool:followed by the encoded body.
Because = and every control character are encoded, no name can forge a line of the fold in section 5. The two pinned servers have plain ASCII names, so digests.json also carries thirteen name-to-key pairs that exercise rules 2 and 3, including a body of exactly 128 characters that stays literal, bodies of 129 and 200 ASCII characters and of 50 two-byte characters that are cut, and two names whose cut lands inside a triplet.
5. Catalog digest¶
With the per-tool map {key: digest} from sections 3 and 4:
- Sort the keys in ascending code-point order (the keys are printable ASCII, so byte order and code-point order agree).
- Write one line per key,
key=digest, joined by a single\nwith no trailing newline. - The catalog digest is
sha256:and the SHA-256 of the UTF-8 bytes of that text.
Reordering the array is therefore not drift, and a change to one definition changes exactly one line. Two edge rules are stated for completeness; neither is exercised by the pinned catalogs:
- Two definitions with the same key fold into one entry, whose digest is SHA-256 over the UTF-8 text
trace.mcp-tool-definition.v1.duplicates\nfollowed by the sorted per-tool digests joined by\n. - Beyond 500 entries, the first 500 keys in sorted order are kept and the rest fold into one entry keyed
tools:overflow, whose digest is SHA-256 overtrace.mcp-tool-definition.v1.overflow\nfollowed by the remainingkey=digestlines joined by\n.
6. Where the values go in the token¶
- The
tool-catalogcomponent'sobserved_digestis the catalog digest of thetools/listthe appraiser observed. - The relying party's requirement for that component sets
expected_observed_digestto the catalog digest of thetools/listit holds for the server it is asking about. - The verifier compares the two strings. A difference is
component_observation_mismatch, as today.
Nothing else in the token changes. The component's profile is whatever appraisal profile produced the status; the vectors use the corpus's example profile. Which profile identifier names this derivation for accepted_profiles is left to the project (section 10).
7. Pinned catalogs and their digests¶
Three catalogs are pinned under catalog/. The first and third were fetched from the servers named, with one initialize and one tools/list request each, and are the tools member of the result as served. The second was not served: it is the first with one sentence appended to the description of ask_wiki_question, and stands for what the server would serve after one definition changed.
| Catalog | File | Served by | Tools | Catalog digest |
|---|---|---|---|---|
| deepwiki | deepwiki-tools-list.json | https://mcp.deepwiki.com/mcp, 2026-10-01 | 3 | sha256:b3d3955a3da0c0eb95a263ac53e4df2efcb0634193b7cbee92337144f4146cbd |
| deepwiki-drifted | deepwiki-tools-list-drifted.json | constructed from deepwiki | 3 | sha256:fffdf1917378cc03057ab6dc7973731123e392adb365f6833dff650281a9dbf6 |
| cloudflare-docs | cloudflare-docs-tools-list.json | https://docs.mcp.cloudflare.com/mcp, 2026-10-02 | 2 | sha256:aa6ec740288222f2e1081056057f05a0d4a38d4114fe3914ba9b95270835e53b |
Per-tool digests:
| Key | deepwiki | deepwiki-drifted |
|---|---|---|
tool:ask_wiki_question | sha256:7b433df44e34a9356d6481fc370283840a598c47b879d0f663952e91d8b3e729 | sha256:a7da4da5eca7b1d7f329a555a7dd748587124dadd75056f375b306129215b4d6 |
tool:read_wiki_contents | sha256:60d4ddd7838f28963920e8687ef644096fb38e8602cb25b8a53149fe87fc72d5 | unchanged |
tool:read_wiki_structure | sha256:9b1330cf1cf19a6b003fc093dc0d92bdea53a47de4cf4efd17b7a0add7945c50 | unchanged |
| Key | cloudflare-docs |
|---|---|
tool:search_cloudflare_documentation | sha256:c4411b558681b396ca09a421f5dad4582804d906f80cd02a6f823d5d6b3d700e |
tool:migrate_pages_to_workers_guide | sha256:8f5e64cb597baefb37e8f11d4325f7fe88d23e4947301ef777897aa2ea8e599b |
The deepwiki definitions each carry name, description, inputSchema, outputSchema and _meta, and no title or annotations; the cloudflare definitions carry annotations on both, outputSchema on one, and no title or _meta. Between them, every allowed member except title is present in at least one definition and absent from another, and the excluded _meta is present in three.
Every value in both tables was recomputed from the pinned files by gen_corpus.py when it built the vectors and, separately, by tests/test_tool_catalog_digest.py.
8. The cases¶
All three vectors share one requirement set: mcp.server (type mcp-server, no pin, as in COMP-MCP-001) and tools.catalog (type tool-catalog) with expected_observed_digest set to the deepwiki catalog digest. They differ only in what the token's tools.catalog component presents.
| Vector | Kind | Presents | Outcome |
|---|---|---|---|
COMP-MCP-004 | positive | the deepwiki catalog digest | valid, composite affirming |
COMP-MCP-005 | counterexample, digest-mismatch | the deepwiki-drifted catalog digest | component_observation_mismatch |
COMP-MCP-006 | counterexample, wrong-subject | the cloudflare-docs catalog digest | component_observation_mismatch |
Digest-mismatch is drift after appraisal: the same server, one definition changed. Of the three per-tool lines, one differs, so a party holding both catalogs can name the tool that moved. The token carries only the fold, so the verifier reports the mismatch and not the tool; the per-tool localization is in the pinned material, not in the token.
Wrong-subject is a correct digest of the wrong catalog: every per-tool digest is right for what docs.mcp.cloudflare.com served, and none of its keys appears in the pinned catalog. The relying party asked about one server and was shown another server's catalog.
Both counterexamples carry the same reason code, and that is a statement about the token, not an oversight in the vectors. The token has no member that names the server a catalog came from, so inside the token the two cases are the same event: the presented digest is not the pinned one. The pinned bytes are what tell them apart. Separating them in the token is exactly the server-to-catalog relationship method that TR-COMP-MCP-002 leaves to the gateway, and this document does not propose one.
tests/test_tool_catalog_digest.py holds each vector to the recorded roles: the pin in its context is the deepwiki digest, the component presents the digest of the catalog named for it, and the expected outcome is as above. It also pins the properties the derivation claims: _meta and unknown members do not change a digest, null and absent are the same and an empty string is not, reordering the array is not drift, drift changes one line, and the wrong-subject catalog shares no key with the pinned one.
9. What this establishes, and what it does not¶
A pinned catalog digest that matches establishes exactly this: the appraiser observed, at appraised_at, a tools/list whose definitions, restricted to the six members above, are the ones the relying party holds. Freshness, authority and the rest of the component's checks apply as the composite proposal says.
It establishes nothing about:
- Runtime behavior. What a tool does when called, whether its implementation matches its description, or what the server does between two observations.
- Definitions not observed. A tool the appraiser did not see has no line in the fold.
- Which server served it. The catalog digest does not contain the server's identity. A valid digest presented for the wrong server is caught only because the relying party pinned a value for the server it meant, which is
COMP-MCP-006. Endpoint identity and transport key binding are what anmcp-servercomponent would rest on and are open (composite proposal, section 11, question 6). - The evidence object. The component's
evidence_refsare not defined here. The servedtools/listobject is a natural candidate for the evidence an appraiser cites, but that choice, and the digest form it would take, is not made in this document. - The gateway's own catalog hash. A gateway may project a catalog into a different digest for its own binding, as the cMCP gateway does with a Merkle root over leaves of a different shape (
tests/test_cmcp_catalog_binding.py). That projection and this derivation are different functions of the same catalog; neither is derivable from the other, and theprofilea component carries is what says which one itsobserved_digestis.
10. Open points for review¶
- A profile identifier. The preimage label
trace.mcp-tool-definition.v1names the derivation and is fixed. Whether the same identifier, or another in the project's namespace, should also name this derivation foraccepted_profilesis the project's to decide; adopting one would not change any digest. - Carrying the per-tool map. The token carries one
observed_digestper component. Whether atool-catalogcomponent should also carry the per-tool map, so that a verifier can localize drift without the pinned bytes, is a schema question this document does not raise. - The evidence reference. Whether the served
tools/listobject should be the component's cited evidence, and under what media type.
11. Reproduction¶
python examples/verifier-token-conformance/gen_corpus.py
python -m pytest tests/test_tool_catalog_digest.py tests/test_conformance_corpus.py
node tools/independent-verifier/verify.mjs examples/verifier-token-conformance/vectors/COMP-MCP-00[456].json
The generator recomputes the three catalog digests from the files under catalog/ on every run; the test recomputes them again without the generator's code and checks digests.json, the vectors and the properties in section 8; the independent verifier reports the expected outcome for each vector.