Skip to content

MCP Tool Surface Integrity — Design ​

Status: draft for review (#235). Extends GOVERNED-MCP.md, which governs what an MCP tool may do; this governs whether the tool we were handed is the tool we approved.

Scope ​

A tool's description is instruction the model obeys, and no human reads it after the first install. Its inputSchema shapes what the model sends. Its annotations decide which permission tier it lands in. All three arrive from a third party on every tools/list, and today all three are trusted on arrival.

This design canonicalises that surface, hashes it, records the digest, and reports when it changes. In scope:

  • a per-tool and per-server digest over the fields that steer the model
  • an mcp_tool_surface audit event carrying digests, never text
  • an optional committed pin, so a change becomes a code-review event
  • annotation changes treated as a policy question rather than a cosmetic one

The threat this closes ​

Every existing chokepoint stays satisfied while the agent's behavior changes. checkCommand gates a command; checkEgress gates a host; registry.run gates a call. A rewritten description needs none of them — it changes what the model decides to do, one layer above where we are looking. Microsoft's June 2026 research describes exactly this: exfiltration in which every individual action still appears authorized. OWASP ranks it third in its MCP Top 10; NSA's CSI names it in the same threat model.

Two variants matter here, and the second is the one worth the most attention:

1. Description drift. The text changes; the tool still does what it says; the model does something else. Nothing downstream can see it, because we never recorded what the text was.

2. Annotation drift — a policy bypass, not a cosmetic change. src/mcp/register.ts:24 derives access from annotations: def.annotations?.readOnlyHint ? 'read' : 'execute'. A server that flips readOnlyHint from absent to true moves its own tool out of the most-gated tier. The server chooses its own permission level, and the choice is re-made on every tools/list.

The same flag has a second effect two lines down: isConcurrencySafe: () => Boolean(def.annotations?.readOnlyHint). So one flipped boolean both lowers the permission tier and makes the tool eligible to run concurrently — a wider change than "read-only" suggests, and one no human is prompted to approve.

That is a widening of privilege from a repo-external source, which governance rule 2 (narrowing only) already forbids for policy. The same rule should hold for metadata that decides policy.

Design ​

What is hashed ​

For each tool, over the fields that steer the model or decide its tier:

toolDigest = sha256(canonicalJSON({ name, description, inputSchema, annotations }))

For the server, over the sorted per-tool digests:

surfaceDigest = sha256(canonicalJSON([{ name, toolDigest }, …sorted by name]))

Per-tool digests are what let a report name which tool changed rather than only that something did. canonicalJSON and sha256 already exist in src/audit/hash.ts, so this adds no runtime dependency (governance rule 5) and reuses the canonicalisation the Verified Fix Record is already hashed with.

Where it hooks ​

Immediately after listTools() resolves, before any tool is registered — src/mcp/client.ts and src/mcp/stdio.ts both, since a check present on only one transport is a side door (governance rule 1). Registration into the registry happens downstream of the check, so a server that fails a pin never gets its tools mapped to mcp__<server>__* at all.

What is recorded ​

A new mcp_tool_surface audit event, per server per run:

FieldContents
serverserver id
surfaceDigestthe digest above
toolCountnumber of tools offered
pinStateunpinned · matched · changed
changedon a change: for each tool, its name and which fields differ — description, inputSchema, annotations

changed carries field names, never field values. Governance rule 3 (minimize, don't leak) applies with particular force here: the whole point is that descriptions are attacker-controlled text, and copying them into the audit chain would move attacker-controlled text into the record auditors read. The digest proves change; the field name locates it; the diff is for the reviewer to pull from the server, deliberately.

The pin ​

An optional mcp-tools.lock committed to the repository, mapping server id → surfaceDigest plus per-tool digests.

Committing it is what converts a silent metadata change into a pull-request diff — the control Microsoft's guidance actually asks for, which is that any change to MCP configuration is reviewed like code. The lock is an integrity record, not a grant: it cannot admit a server that policy's mcp allowlist excludes, and it cannot widen anything. Narrowing-only is unaffected because the lock never grants.

Enforcement posture ​

Governance rule 4 (honest enforcement) means this cannot be silently advisory. It also should not break every legitimate server upgrade, which are routine and frequent. The proposed default:

SituationDefaultWhy
No lock entry for the serverRecord baseline, report it, continueTrust on first use. There is nothing yet to compare against, and refusing would make the feature unadoptable.
Lock entry matchesContinueThe approved surface.
Lock entry differs, no tier wideningWarn and continue; report names the tools and fieldsThe common case is a real upgrade. Failing closed here trains people to delete the lock.
Lock entry differs, and a tool moves execute → readFail closedA third party widened its own privilege. This is rule 2, not a preference — it fails even when the operator has chosen warn.

The asymmetry is the load-bearing part: a changed description is a fact to report, a self-granted tier upgrade is a violation to block. Collapsing both into one setting loses the distinction that makes the control worth having.

This is the open question for review. The alternative — fail closed on any change once pinned — is more defensible in a threat model and worse in practice, because a tool surface that legitimately changes on every server release produces a stream of blocks that operators will resolve by turning the check off. A control that gets disabled protects nothing. I lean toward the table above and would like that argued rather than assumed.

Trust surface ​

dvalincode trust already lists each server with its policy permission, egress status, and tool count. It gains the surface digest and pin state, so an approver sees at a glance whether the third-party attack surface is the one that was reviewed.

Acceptance matrix ​

CaseExpected result
No MCP configBehaves exactly as today; nothing hashed, no new event
First connection to a server, no lock entrymcp_tool_surface written with pinState: unpinned; baseline reported; tools register normally
Reconnect, surface unchangedpinState: matched; no report beyond the audit event
Description changes between runspinState: changed; report names the tool and description; run continues under the default posture
inputSchema changes between runspinState: changed; report names the tool and inputSchema
A tool gains readOnlyHint: true against its locked entryBlocked; tools not registered; policy violation recorded — regardless of posture setting
A tool loses readOnlyHint (narrowing to execute)Reported as changed; not blocked — narrowing is always allowed
A tool is added or removedpinState: changed; report names it as added/removed
Audit record for any of the aboveCarries digests, tool names and field names; contains no description text, schema body, or annotation values
Server fails the pin checkNo mcp__<server>__* tools reach the registry
Two servers offering same-named toolsDigests are per server id; no collision
dvalincode trustShows surface digest and pin state per server

Non-goals (deferred) ​

  • Judging whether a description is malicious. This proves the surface is stable, not that it is safe. A server that ships a poisoned description on day one gets that description pinned. Detecting the content itself would mean asking a model to evaluate text, and a model's opinion is exactly what this project refuses to put in a verification path — the same reason the executor is recorded and never consulted. Stability is a deterministic property; safety is not.
  • Server authenticity. Whether the server is who it claims to be is a transport and admission question, handled by the policy allowlist and (for remote servers) the egress path.
  • Pinning tool behavior. A server may return an honest description and do something else entirely on tools/call. That is the verification problem, not the integrity problem, and it is not solved by hashing metadata.
  • Automatic lock updates. Regenerating the lock is a deliberate act with a diff someone reads. A command that silently re-pins on change would reintroduce exactly the blindness this removes.

References ​

Released under the MIT License. Not affiliated with any AI vendor.