What an MCP server can check today
An MCP server (Python SDK, FastMCP) that verifies a delegation chain before it runs a tool. Offline, in-memory
transport, no API key. Verified against mcp 1.28.1 and the MCP roadmap post of 2026-08-22, on 2026-08-25.
What MCP built well
MCP's authorization (spec 2026-07-28) is careful about the confused deputy: resource indicators and protected-resource metadata are MUSTs, servers must accept only tokens meant for them and must never pass a token through. Every hop gets its own audience-bound token. That is the right foundation, and this recipe stands on it.
What this recipe shows
- The request carries no agent authority today.
CallToolRequestParamshasname,arguments,meta,task— nothing that says which agent is calling on whose behalf with how much of it. The roadmap post (2026-08-22) names it: "more and more of the callers are agents … delegating narrower authority to sub-agents" — with no spec text yet. The test next to this file pins that and names the day it changes. - A server can already check a chain. The client puts attenu-guard Delegation Tokens (root → calling agent)
in
_meta— out-of-band of the tool arguments, where the roadmap places agent identity. The server loads the chain offline (signatures, parent hashes, depth, child ⊆ parent at every hop), checks the leaf authority against the tool's scope and request context, records the decision on a hash-chained ledger, and only then runs the body. The reader's chain cannot export; a spliced chain fails; no chain is a deny. - The record verifies offline — the server's ledger, and the client's own bundle, with no service involved.
pip install 'attenu-guard' mcp
python examples/integrations/mcp/server_verifier/demo.py
# RUN_LIVE=1 python examples/integrations/mcp/server_verifier/live_smoke.py # the same server over stdio
Expected: the control server runs the export · the guarded server allows the exporter, denies the reader, refuses
the spliced chain and the missing chain, allows the reader's read · tool bodies that ran = the two allowed calls ·
ledger verifies · RESULT: OK. Exit 3 = the SDK now carries an authority field (premise changed).
Trust boundary
The check lives at the MCP boundary, inside the server, before the tool body. It does not see a direct Python call to the underlying function (the test proves it runs), or any other route to the resource behind the tool. Inside the boundary: a denied call never runs its body; a tool the server does not declare does not exist; retries stay denied and each attempt is on the ledger; if the ledger cannot be written the body does not run.
Evidence manifest
| Claim | Pinned to | Test |
|---|---|---|
| No agent-authority field in the tool-call request | mcp==1.28.1, CallToolRequestParams.model_fields; roadmap post 2026-08-22 |
test_semantic_request_carries_no_agent_authority |
| Allowed / denied / spliced / missing chain behave as stated; side-effect oracle | this recipe | test_side_effect_oracle_*, test_bypass_* |
| Ledger verifies; tampered ledger fails | AuditLog.verify |
test_bypass_tampered_ledger_fails |
Related: OWASP Top 10 for Agentic Applications 2026 — ASI03, ASI07, ASI08 · Agent Baseline AUT-03 ·
docs/DENIAL-CONTRACT.md · the Delegation Token wire format in
docs/.
What remains MCP's
The transport, the per-hop OAuth model, and the eventual standard field for agent identity and authority — when it
lands, this server reads it from there instead of _meta; the verification does not change.