{"uuid": "b716fca6-ac8c-481e-97fe-3fc3389b5c83", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2025-54136", "type": "seen", "source": "https://gist.github.com/elang2/7aeddf9eecc4e5e9f880d2ed041b6168", "content": "# Scope: Tool-Definition Integrity (Option #1)\n\n## Summary\n\nA canonical form for MCP tool definitions, cross-language conformance vectors that prove byte-identical digests, and a drift-detection chain record emitted when the gateway observes a tool definition change mid-session. Packaged as a gateway feature + vector corpus + SEP draft.\n\n---\n\n## 1. What needs canonicalization\n\nAn MCP tool definition (from `tools/list` response) has these fields:\n\n| Field | Type | Required | Divergence risk |\n|-------|------|----------|-----------------|\n| `name` | string | yes | Unicode normalization, astral-plane characters |\n| `description` | string | no | Unicode, trailing whitespace, newline variants |\n| `inputSchema` | object (JSON Schema) | yes | Key ordering, float defaults, nested objects, $ref resolution |\n| `annotations` | ToolAnnotations | no | Boolean hints, key ordering, optional fields absent vs. present |\n\n**ToolAnnotations fields:** `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title` (all optional, boolean/string).\n\n**inputSchema is the main minefield:**\n- JSON Schema allows `default`, `minimum`, `maximum`, `multipleOf`, `exclusiveMinimum`, `exclusiveMaximum` \u2014 all potentially floats\n- `properties` is an object with arbitrary key ordering\n- Nested `items`, `additionalProperties`, `oneOf`/`anyOf`/`allOf` produce deep structures\n- `enum` values can be any JSON type including floats and null\n- `$ref` may or may not be resolved before hashing (different implementations differ)\n\n---\n\n## 2. Canonical form design\n\n### Recommendation: Use RFC 8785 JCS for tool-definition digests\n\n**Rationale:** This is the same canonicalization split already proven for audit records:\n\n| Layer | Form | Handles floats? | Purpose |\n|-------|------|-----------------|---------|\n| Signing form (audit records) | Tuple-array, M/L tags, integer-only | No | Chain integrity, attestation signatures |\n| Content digests (request/response) | RFC 8785 JCS | Yes | Binding payloads to records |\n| **Tool-definition digests (NEW)** | **RFC 8785 JCS** | **Yes** | **Pinning tool schemas** |\n\nTool definitions contain arbitrary JSON Schema which allows floats. The signing form (`canonicalizeValue()`) rejects floats by design. Forcing float-to-string conversion would make the digest non-obvious to verify (a consumer would need to know about the pre-encoding). JCS handles floats natively (IEEE 754 double \u2192 shortest representation) and is already well-understood.\n\n**The digest computation:**\n\n```\ntoolDefinitionDigest = SHA-256(JCS(toolDefinition))\n```\n\nWhere `toolDefinition` is the full tool object as returned by `tools/list` (name + description + inputSchema + annotations), canonicalized with RFC 8785 before hashing.\n\n**Why not `canonicalizeValue()`:**\n- It would work for the name/description/annotations (no floats there)\n- It breaks on inputSchema if any numeric constraints exist\n- Using two different forms for one digest would be confusing\n- JCS is the standard answer for \"deterministic hash of arbitrary JSON\"\n\n**Cross-language guarantee:** JCS is specified by RFC 8785 with explicit test vectors. Python has `canonicaljson`, JS has multiple implementations. The remaining divergence risk is in inputs JCS itself doesn't normalize (lone surrogates, $ref resolution, absent-vs-null). Our vectors test those.\n\n### What `canonicalizeValue()` still provides\n\nThe gateway's signing form is used for the **chain record** itself (the drift-detection record is signed and chained like any other). The tool-definition digest is a content hash that appears as a field within that signed record \u2014 same pattern as `extensionsDigest` and `decisionContextDigest`.\n\n---\n\n## 3. Drift detection record design\n\nNew record type in the chain: `tool_drift`\n\n```typescript\ninterface ToolDriftRecord {\n  id: string;\n  type: \"tool_drift\";\n  timestamp: string;\n  toolName: string;\n  namespace: string;\n  previousDefinitionDigest: string;  // SHA-256 of JCS(previousToolDef)\n  newDefinitionDigest: string;       // SHA-256 of JCS(currentToolDef)\n  detectedAtRecord: number;          // which call number noticed the change\n  previousHash: string;              // chain linkage\n  attestation?: string;              // signed by gateway\n}\n```\n\n**Integration with existing chain:**\n- Chained like checkpoint and chain_break records (has `previousHash`)\n- Signed with the same signer (Ed25519 or HMAC)\n- Canonical form follows the same tuple-array pattern as other record types\n- Gateway emits this BEFORE the tool call that noticed the drift (the call itself proceeds; the drift record is evidence, not enforcement)\n\n**Detection mechanism:**\n1. On startup, gateway calls `tools/list` and computes `SHA-256(JCS(tool))` for each tool \u2192 stores as baseline\n2. Periodically (configurable) or on each `tools/call`, gateway re-fetches `tools/list`\n3. If any tool's digest differs from baseline, emit a `tool_drift` record into the chain\n4. Update baseline to new digest\n\n**Configuration:**\n\n```yaml\ntoolIntegrity:\n  enabled: true\n  checkInterval: \"every_call\" | \"periodic\" | \"session_start\"\n  periodicIntervalMs: 30000\n  action: \"record\" | \"record_and_block\"  # block = deny calls to drifted tools\n```\n\n---\n\n## 4. Conformance vectors\n\nFile: `test/vectors/tool-definition-canonicalization.json`\n\n### Positive vectors (expected digest must match across JS and Python):\n\n| # | Description | Tests |\n|---|-------------|-------|\n| P1 | Minimal tool (name + inputSchema only) | Baseline |\n| P2 | Tool with all fields populated | Full coverage |\n| P3 | Tool with nested inputSchema (properties with objects) | Deep key sorting |\n| P4 | Tool with inputSchema containing float default (0.1) | JCS float handling |\n| P5 | Tool with inputSchema containing integer default (42) | Integer vs float distinction |\n| P6 | Tool with annotations (all boolean hints) | Optional field handling |\n| P7 | Tool with Unicode description (astral-plane emoji \ud835\udd73) | Surrogate pair handling |\n| P8 | Tool with BMP Unicode in name (caf\u00e9) | Common non-ASCII |\n\n### Negative vectors (MUST NOT produce the same digest / must reject):\n\n| # | Description | Tests |\n|---|-------------|-------|\n| N1 | Same tool with keys in different source order | Key-order independence |\n| N2 | Tool with `description: null` vs `description` absent | null vs missing distinction |\n| N3 | Tool with trailing whitespace in description | Whitespace significance |\n| N4 | Tool with lone surrogate in name (U+D800) | Must reject / error |\n| N5 | Tool with integer 1 vs float 1.0 in default | Type distinction under JCS |\n| N6 | Tool with NaN in inputSchema minimum | Must reject (NaN not valid JSON) |\n| N7 | Tool with duplicate keys in inputSchema | Implementation-defined behavior |\n| N8 | Tool with empty properties {} vs properties absent | Structural distinction |\n| N9 | Tool with -0 as a default value | JCS normalizes to 0 |\n| N10 | Tool with very large integer (&gt; 2^53) | Safe-integer boundary |\n| N11 | Two tools differing only in annotation order | Key-order independence in nested |\n| N12 | Tool with $ref unexpanded vs $ref resolved inline | Expansion policy |\n\n### Drift vectors (paired before/after definitions):\n\n| # | Description | Tests |\n|---|-------------|-------|\n| D1 | Description text changed | Drift detected |\n| D2 | inputSchema property added | Drift detected |\n| D3 | Annotation value flipped (readOnlyHint: false \u2192 true) | Drift detected |\n| D4 | Key order changed but content identical | Drift NOT detected (same JCS output) |\n\n---\n\n## 5. SEP draft structure\n\n**Title:** SEP-XXXX: Tool Definition Integrity via Deterministic Digests\n\n**Sections:**\n\n1. **Abstract** \u2014 One paragraph: intermediaries and clients need a deterministic way to detect tool-definition changes. This SEP specifies a canonical digest computation and a notification mechanism.\n\n2. **Motivation** \u2014 CVE-2025-54136, OWASP MCP03:2025, Invariant Labs recommendations all assume hash-pinning works but don't specify how. Cross-language JSON serialization diverges on key order, floats, Unicode.\n\n3. **Specification**\n   - 3.1 Digest computation: SHA-256 over RFC 8785 JCS canonical form of the full tool object\n   - 3.2 Scope: the digest covers `name`, `description`, `inputSchema`, and `annotations` (the complete tool object from `tools/list`)\n   - 3.3 Normalization: `$ref` MUST be resolved before canonicalization (no external references in the digested form). Absent optional fields are omitted (not null).\n   - 3.4 Constraints: lone surrogates MUST be rejected. NaN/Infinity MUST be rejected.\n   - 3.5 Server capability: servers MAY advertise `toolDefinitionDigests: true` in capabilities\n   - 3.6 Digest field: `tools/list` response MAY include a `digest` field per tool (server-computed)\n   - 3.7 Drift notification: new notification type `notifications/tools/definitionChanged` with fields `toolName`, `previousDigest`, `newDigest`\n\n4. **Conformance** \u2014 Cross-language test vectors as normative. Two independent verifiers (JS + Python) MUST produce byte-identical digests for all positive vectors.\n\n5. **Security considerations** \u2014 Rug-pull detection guarantees. What this does NOT prevent (first-use trust). Relationship to ETDI (complements, does not replace OAuth-level trust).\n\n6. **References** \u2014 RFC 8785, OWASP MCP Top 10, CVE-2025-54136, Invariant Labs advisory, mcp-audit-gateway DOI\n\n---\n\n## 6. Deliverables and timeline\n\n| Deliverable | Description | Effort |\n|-------------|-------------|--------|\n| **Gateway feature** | `tool_drift` record type, JCS digest computation for tools, periodic/per-call checking, config option | 3-4 days |\n| **Conformance vectors** | `tool-definition-canonicalization.json` with ~25 vectors (positive + negative + drift pairs), JS verifier, Python verifier | 2-3 days |\n| **SEP draft** | Markdown document following MCP SEP template, referencing vectors and implementation | 1-2 days |\n| **Paper reference** | Update README/paper to mention tool-definition integrity as second use case | 0.5 days |\n\n**Total: ~2 weeks (part-time)**\n\n**Dependencies:**\n- Need a JCS library for the JS implementation (candidates: `canonicalize` npm package, or implement the small subset needed)\n- Python: `canonicaljson` package\n- No external dependencies for the SEP draft\n\n**Sequence:**\n1. Implement JCS digest for tool definitions in gateway (validates the approach works)\n2. Write vectors simultaneously (TDD: vectors define expected behavior)\n3. Implement `tool_drift` record type and chain integration\n4. Write Python verifier for vectors\n5. Draft SEP referencing shipped implementation + vectors\n6. Open discussion/PR on modelcontextprotocol repo\n\n---\n\n## Key design decision: $ref handling\n\nThe biggest open question is whether `$ref` must be resolved before hashing. Two options:\n\n**Option A: Require resolution** (recommended)\n- Pro: Deterministic \u2014 two servers returning the same logical schema always produce the same digest regardless of whether they use $ref or inline\n- Con: Requires a $ref resolver in the canonical form computation\n- Implementation: Simple for MCP since $ref in tool schemas is typically internal (no external URLs)\n\n**Option B: Hash as-is**\n- Pro: Simple \u2014 just hash whatever the server returned\n- Con: Same logical schema with/without $ref produces different digests \u2014 false drift alarms\n- This would make drift detection unreliable across server versions that refactor schemas\n\n**Recommendation: Option A** \u2014 resolve internal $ref before canonicalization. External $ref (URLs) MUST be rejected (the digest must be self-contained).\n", "creation_timestamp": "2026-08-23T21:05:56.661865Z"}