Short version: in the 2026-07-28 MCP revision a server's tool list may legally vary by the token on the request, which turns tools/list into an authorization surface instead of a static manifest. readOnlyHint remains a self-declared annotation with no enforcement behind it, so your risk tiers belong in an allowlist you version. These ten tips are for the person who already has MCP servers in production, an agent calling them, and nothing to hand an auditor who asks what the agent can write to.
Three mechanisms in that revision carry authorization consequences and nothing connects them in the vendor docs: token-varying tool lists, cacheScope on those lists, and x-mcp-header, which mirrors tool parameters into HTTP headers. A model trained before July will describe the old shape, where the tool list is a fixed property of the server. Getting a correctly audienced token in the first place is a separate job covered in fixing MCP OAuth 2.1 before the July 28 rewrite; if you are tracking this against a control framework, the renumbering in the OWASP LLM Top 10 2026 crosswalk moved the IDs you probably cite. What follows is the layer underneath both: what an authenticated agent may actually invoke.
The tips
- Treat
readOnlyHintas a claim, never as a control. The spec's tools page carries a warning that clients "MUST consider tool annotations to be untrusted unless they come from trusted servers," and the MCP project's March 2026 post on annotations puts it plainly: an untrusted server can lie, and annotations "aren't enforcement." A policy engine that auto-approves onreadOnlyHint: truehas delegated risk tiering to the party being tiered. Hold the tier in your own allowlist and let the server's annotation do one job, raising an alert when it drifts from what you recorded. - Hash the tool surface and alert on the diff. Servers may change their tool set at any time and announce it with
notifications/tools/list_changed, so the manifest you reviewed in March is not the one answering calls today. Baseline the name, description,inputSchemaandannotationstogether, because a rug pull can move a tool from read to write without touching the name.
curl -sX POST "$MCP_URL" -H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
| jq -S '[.result.tools[] | {name, description, annotations, inputSchema}]' | sha256sum
- Filter the tool list by the token rather than by client config. This is the sanctioned mechanism and it is new. The spec states the tool set "MUST NOT vary per-connection or as a side effect of other requests," then adds that it "MAY vary by the authorization presented on the request, for example returning only the tools the caller's granted scopes permit, since credentials are per-request input, not connection state." A read-scoped agent should never see a write tool in its context window at all. Prove it by listing under two tokens and diffing the name arrays.
- Set
cacheScope: "private"the day that filtering goes live. The caching page's Security Considerations describe the trap directly: atools/listresult marked"public""may be cached by a client and may be shared outside of the initial request's authorization context (i.e. different access tokens can leverage the same cache)." A scope-filtered list served aspublicthrough a shared gateway hands the privileged tool set to every caller behind it. The same section warns that servers "MUST NOT rely oncacheScopealone to prevent unauthorized access," so treat it as a leak stopper sitting on top of a real check.
... | jq '.result | {cacheScope, ttlMs, count: (.tools|length)}'
- Key the allowlist on the canonical resource URI plus the tool name. Tool names are unique only within a server. The spec notes that aggregating proxies "MAY encounter naming collisions (for example, two servers each exposing a
searchtool)," and thatserverInfo.name"is not guaranteed to be unique across servers and SHOULD NOT be relied upon for disambiguation." An entry readingsearch: allowis ambiguous by construction. Use the RFC 8707 canonical resource value the token was issued for, the same string you put in theresourceparameter.
- resource: "https://mcp.example.com/mcp" # the RFC 8707 resource value
tool: "search"
tier: read
- Audit
x-mcp-headeron every tool schema you accept. A parameter may carryx-mcp-header, which mirrors its value into anMcp-Param-{name}HTTP header so load balancers and WAFs can route on it without parsing the body. The spec tells server developers they "SHOULD NOT mark sensitive parameters (passwords, API keys, tokens, PII) withx-mcp-header, as header values are visible to network intermediaries." It cuts both ways, and the second edge is useful: a gateway can deny onMcp-Param-Regionwith zero JSON parsing, which is the cheapest enforcement point you will find in this protocol.
... | jq '.result.tools[] | select([.inputSchema.properties[]?["x-mcp-header"]] | length > 0) | .name'
- Make writes earn a step-up and match on the exact 403 shape. Issue the agent read scopes at session start and let the server challenge for anything more. The authorization spec fixes the response format:
HTTP 403 ForbiddenwithWWW-Authenticate: Bearer error="insufficient_scope", scope="files:write", and it tells servers to emit every scope the operation needs in one challenge instead of trickling them out call by call. Assert on that header text in CI, since a gateway that returns 401 or a bare 403 will send your client into a retry loop. - Bound the token lifetime, because granted scope only grows. The step-up flow requires clients to compute "the union of the client's previously requested scope set and the scopes from the current challenge." Across a long-running session the agent accumulates permissions and sheds none of them, so expiry is the only thing that resets the union. That makes TTL an authorization control, and the reasoning is the same one behind killing static ServiceAccount tokens for bound ones in Kubernetes.
- Re-authorize stateful handles on every single call. The sharpest line in the whole revision is the non-normative guidance on stateful tools: "a handle is a name, not a capability. The server should validate the caller's authorization against the handle on every call." For unauthenticated servers, "where the handle is necessarily a bearer token," the spec asks for UUIDv4-grade entropy and a bounded lifetime. Test it in ten minutes by creating a handle with token A, then replaying that handle with token B and confirming you get a denial rather than a result.
- Decide your policy on
input_requiredbefore an agent meets one. Atools/callcan returnresultType: "input_required"along with anelicitation/createrequest; the spec's own example asks the user for a GitHub username. That is a server-controlled prompt arriving mid-call inside an automated loop, which is a prompt injection delivery path with a protocol blessing. Route elicitation to a human or fail the call, and never auto-fill it from a secret store. MRTR results also "MUST NOT be cached," so these calls sidestep tip 4 entirely.
Wrap-up
The habit that carries the rest: keep the risk tier in an artifact you version, and let the server's annotations only ever raise an alert. The scope-filtered list, the private cache scope, the step-up 403 and the bounded token all follow from refusing to let a tool grade its own homework, which is the same default-deny posture that keeps Kubernetes admission control from failing open.
Turning this into evidence takes an afternoon. Write a CI job that lists tools under a read-scoped token, asserts zero write-tier names come back, then calls one of those write tools directly and asserts a 403 carrying error="insufficient_scope". That output is what goes in the review binder. The expensive part lands afterward: running the inventory across every MCP server your teams have quietly wired up, agreeing a tier vocabulary that survives contact with a second team, and standing up the gateway that enforces it. None of this addresses prompt injection, which remains a separate problem with separate controls.
Comments
Be the first to comment.