Skip to content
Chapter 4 of 8 · Tools over MCPEnforcement in the tool handler

14 min read

Enforcement in the tool handler

On this page

The previous chapter's finding was that a behavioural hint cannot stop a call. The obvious follow-on question is where a stop can live, and the answer is narrower than it first appears: the only place is the handler, because it is the only code in the path that is yours, runs before the effect, and can see who is asking.

Everything else in the path belongs to somebody else. The host decides whether to show a dialog, the model decides which tool to select, the client library decides whether to retry, and none of those decisions is under your control or on your release cycle. A server that assumes any of them is behaving a particular way has bet its safety properties on software it does not ship and will not be told about when it changes.

Enforcement, in this chapter, means a decision that can refuse. It needs three inputs a listing entry cannot supply: a caller, established from something the server validated itself; the arguments as they will actually be used, canonicalised rather than as they were typed; and a policy that can be asked about this caller and this resource. Everything below is the shape of those three, and the reason a two-line annotation diff feels like it closes an authorization ticket is that none of them is visibly missing until the least generous consumer arrives.

The same call, three answers

Running one destructive tool through three connected applications produces three behaviours, all of them compliant:

ConsumerReads annotations?Before delete_fileOn a 30 s timeoutWho the caller is
Desktop chat clientyesmodal: Allow once / Always / Denydoes not retrysigned-in human, session token
IDE extensionpartiallyinline banner, auto-approves after a per-workspace allowlist is setretries oncesigned-in human, workspace token
CI job on a bare clientnonothingretries per its own backoff policya service account, or nothing at all

The first two rows describe policies written by two vendors on two release cycles, either of which can change in a point release without telling you. The third row is not a misconfiguration; a protocol client that executes what it is asked to execute is the correct baseline, as the bare-client trace in the previous chapter showed.

There is a second-order effect worth naming. Because each host's policy is its own, the union of behaviours across your consumers is what you are actually exposed to, and it converges on the weakest one as soon as somebody adds a fourth integration. Security properties do not average. A team that reasons from the client they use daily and demo will not be consulted when a different team points automation at the same connection string, and nothing about the protocol requires them to be told.

"Do not average" is worth making precise, because the whole argument rests on it. Your exposure is the set of things that can happen, and a thing can happen if any consumer permits it. So the effective policy across three consumers is not the middle one; it is the union of what each allows, which is the same as the least restrictive of the three. Adding a fourth consumer can enlarge that union and can never shrink it. This is the reverse of how latency or availability compose, where one bad participant drags an average down and a good one pulls it back up — and it is why "most of our traffic goes through the client with the modal" answers a question about volume when the question asked was about possibility.

The comparison this most resembles is a shared internal HTTP API whose only access control is a confirmation step in one of the three web front-ends that call it. Nobody would defend that arrangement for a REST service, because the reasoning is familiar: the front-end is one caller among several, its checks protect its own users only, and the service is one curl away from having no checks at all. MCP invites the same mistake in a less obvious costume, since the "front-end" here is a product with a permission UI polished enough that it reads as infrastructure. It is still one caller.

A decision needs an identity and a place to be made

Enforcement is a function of three things: who is calling, what they are asking to do, and a check that runs before the effect. Annotations supply none of these. The host's dialog supplies a human's consent, which is valuable and is not the same as authorization, since consent from a user who is not entitled to the resource is still not entitlement.

Flowchart: tools/call arrives → Credential present and valid for this server?; Credential present and valid for this server? → no; no → 401 / refuse: no caller; Credential present and valid for this server? → yes; yes → Resolve principal: subject, tenant, scopes; Resolve principal: subject, tenant, scopes → Arguments legal? path canonicalised, inside allowed root; Arguments legal? path canonicalised, inside allowed root → no; no → Refuse: bad argument; Arguments legal? path canonicalised, inside allowed root → yes; yes → Principal permitted on this resource?; Principal permitted on this resource? → no; no → Refuse: not authorised; Principal permitted on this resource? → yes; yes → Perform side effect; Perform side effect → Return result.

Every branch in that diagram is code in your process. None of it is reachable from a listing entry, which is why the annotation approach cannot be made to work by declaring the flags harder.

The credential question is where most implementations are thin. When the transport is HTTP, the Model Context Protocol treats your server as an OAuth 2.1 resource server, which is an API that holds no passwords of its own and instead accepts short-lived access tokens minted by a separate identity provider: the client presents such a token in the Authorization header, and your server validates its signature against the issuer's published public keys, the JWKS, before believing anything the token says. The validation that matters most is the audience check. A token issued for a different resource, presented to you by a host that happens to hold it, must be rejected even when the signature verifies and the token has not expired, because accepting it makes your server a confused deputy — it would be acting on a credential that was never meant to authorise anything here, on behalf of whoever handed it over.

python
import os
from dataclasses import dataclass
from jose import jwt, JWTError

RESOURCE_URL = "https://files.internal.example.com/mcp"
JWKS = load_jwks(os.environ["ISSUER_JWKS_URL"])  # cached, refreshed hourly


@dataclass(frozen=True)
class Caller:
    subject: str
    tenant: str
    scopes: frozenset[str]


class Unauthenticated(Exception):
    pass


def caller_from_request(auth_header: str | None) -> Caller:
    if not auth_header or not auth_header.startswith("Bearer "):
        raise Unauthenticated("missing bearer token")
    try:
        claims = jwt.decode(
            auth_header.removeprefix("Bearer "),
            JWKS,
            algorithms=["RS256"],
            audience=RESOURCE_URL,       # token must be minted for THIS server
            issuer="https://id.internal.example.com/",
        )
    except JWTError as exc:
        raise Unauthenticated(str(exc)) from exc
    return Caller(
        subject=claims["sub"],
        tenant=claims["tenant"],
        scopes=frozenset(claims.get("scope", "").split()),
    )

With a Caller in hand the tool handler has something to check against. The check belongs immediately before the side effect, in the same function, with no path around it:

python
ALLOWED_ROOT = Path("/srv/workspaces")


@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    caller = caller_from_request(current_request_headers().get("authorization"))

    if name == "delete_file":
        target = (ALLOWED_ROOT / caller.tenant / arguments["path"].lstrip("/")).resolve()
        if not target.is_relative_to(ALLOWED_ROOT / caller.tenant):
            return refusal(f"path escapes the workspace root for tenant {caller.tenant}")
        if "files:write" not in caller.scopes:
            return refusal("token lacks files:write")
        if not await policy.may_delete(caller.subject, target):
            return refusal(f"{caller.subject} is not permitted to delete {target}")
        await trash.move(target, retention_days=7)
        return [TextContent(type="text", text=f"moved {target} to trash, recoverable for 7 days")]

The audience check deserves a concrete failure to hang on, because it is the one most often skipped by teams who otherwise validate carefully. A host connected to six servers holds six access tokens. If your server accepts any well-signed token from your organisation's issuer without checking that it was minted for https://files.internal.example.com/mcp, then a token issued for the analytics server — with analytics:read in its scopes and a user who has no file permissions at all — verifies against your JWKS, parses cleanly, and hands you a sub you will happily look up in your policy store. Whether that user then gets file access depends on how your policy code treats an unfamiliar subject, and "deny by default on unknown subjects" is a property most policy stores do not have. Adding one line of audience= turns the whole class of confusion into a rejection at parse time.

The step that makes that failure possible is the one it is easiest to read past. Signature verification asks exactly one question — was this token minted by the issuer whose key I hold — and your organisation's issuer mints tokens for every service in it, signed with that key. A valid signature is therefore equally consistent with a token for the analytics server, for the wiki, or for you, so the check that felt like the strong one is not discriminating between them at all. Expiry does not discriminate either: a live token for another service is still live. The audience claim is the only field in the token that names who it was minted for, which makes checking it not an extra precaution on top of the others but the first check in the sequence that is about you rather than about the issuer.

Three properties of that handler are worth reading deliberately. The path is resolved and then checked against a per-tenant root, so ../../srv/artifacts fails on the containment test rather than on a string comparison that .. defeats. The scope check and the per-resource policy check are separate, because a token that carries files:write says the caller may write somewhere, and the policy call says whether they may write here. And the destructive operation is soft — a move into a trash area with a retention window — which converts a class of authorisation mistakes from permanent to recoverable at a cost of some disk.

Keeping the scope check and the policy check apart is what makes the handler auditable a year later. A scope is a property of the token, granted when it was issued, and it is coarse by construction — the issuer had no idea which file this call would eventually name. A policy lookup is a property of the resource, evaluated now, with the resolved target in hand. Collapsing the two means either trusting a coarse claim to settle a question about one specific file, or asking a policy store a question about tokens that it has no way to answer. The separation also makes the refusals legible: "token lacks files:write" is a message for whoever provisions credentials, and "not permitted to delete this" is a message for whoever administers the resource, and in most organisations those are different people.

The soft delete is doing more than it appears to, because it changes what has to be right. Every check above it is a prediction that this call ought to be allowed, and predictions are sometimes wrong: a policy store lags a leaver by a day, a tenant root is misconfigured, a scope granted widely during an incident is never taken back. A hard delete turns each of those into permanent data loss, so the correctness of the outcome rests entirely on the correctness of the checks. A retention window turns the same mistakes into a week in which somebody can notice, so the checks have to be right or the mistake has to be caught. That is two independent chances rather than one, bought with disk.

The stdio case looks different and is not

Local servers launched over stdio have no Authorization header, and the temptation is to conclude that authorization does not apply. What is actually true is that the identity question has already been answered: the host spawned your process as a subprocess of a signed-in user's session, so the server runs with exactly that user's ambient authority — their filesystem permissions, their environment variables, their SSH agent. There is one principal, and it is whoever is at the keyboard.

Two questions are being settled there and it is worth keeping them apart, because merging them is what produces the conclusion that authorization does not apply. The identity question is genuinely answered, and answered by something stronger than a token: the operating system placed this process under that user's account, and nothing in a request could claim otherwise. The authority question is not answered — it is answered maximally, which is a different thing wearing the same clothes. Ambient authority means the process begins with everything its launcher has, so the only way for it to hold less is for somebody to have arranged that on purpose. Under HTTP a handler builds authority upwards from nothing, and a missing step is visible as a missing check. Under stdio it has to carve authority downwards from everything, and a missing step looks like nothing at all.

This is the same authority model as a shell script the user runs, and it has the same consequences: the server cannot be less privileged than its launcher by accident, only by design. That answers who, and leaves what entirely open, which is why the meaningful control for a stdio server is scope at launch rather than a permission check per call. A filesystem server that takes its allowed roots as command-line arguments and refuses anything outside them is doing real enforcement; the same server with an implicit root of / is not, and the fact that only one user can reach it is no comfort when that user's model is the thing choosing paths. Ambient authority also means the blast radius of a prompt-injected instruction is the user's whole account, so the containment check in the handler carries more weight locally, not less.

Testing from the least generous consumer

Verifying any of this is best done from the least generous consumer you have rather than the most generous. The desktop client's modal makes every test pass, since a human clicks Deny and nothing happens; the test that means something drives a bare client with no token, with an expired token, with a token minted for another audience, and with a valid token belonging to a user who lacks the permission, and asserts a refusal in all four cases. That suite runs in a couple of seconds against an in-process server and is the artefact that lets someone say the server is authorised, in a way that reading the annotations never could.

The reason a test is needed at all, rather than a careful reading of the handler, is what the quiet period in the scenario below demonstrates. A missing check produces no output. It does not raise, it does not log, it does not move a metric, and it changes nothing anybody can observe until a caller arrives who would have been refused by it. So a stretch of days with no incident is not weak evidence that a ticket was closed correctly — it is no evidence at all, because an authorised server and an unauthorised one emit identical signals under traffic that either would have allowed. The only way to generate a signal is to send the request that ought to be refused and check that it was, which is precisely what the four cases above do.

A permission check in the handler does not tell you whether a human agreed to the operation. It tells you the operation was permitted. Those diverge constantly: the CI service account was, arguably, entitled to delete files under its own workspace, and the problem was that nobody wanted that particular deletion. Consent is genuinely the host's to collect, and the protocol's elicitation mechanism lets a server request input mid-call — but a host is free not to implement elicitation, so a server that blocks on it has made its correctness depend on the same optional behaviour it was trying to escape. The durable version of "make the human agree" is to make the operation cheap to undo and to say so in the result, which is what the seven-day trash window is for.

It also does not defend the model's judgement about which tool to call. Every check above runs after selection; it can refuse a call, and refusing is not the same as the call never being attempted. A model that keeps attempting a refused deletion in a loop is a real production pattern, and it is a description problem rather than a permission problem — the subject of the seventh chapter of this topic, on descriptions and tool selection.

Finally, none of this replaces the annotations. Declaring destructiveHint: true on delete_file remains correct and useful, because the desktop client's modal is worth having for the traffic that goes through it. The mistake was never declaring the hint; it was recording the hint as the whole of the work.

The rule that generalises past file servers is short. Anything you would be unwilling to have happen without a check must be checked by your code, before your effect, using evidence your code validated — not a flag you declared, not a dialog somebody else's product shows, and not the assumption that every caller looks like the caller you tested with.

Which raises a question the handler above has been quietly deferring. Every failure branch in it calls refusal(...), and there are two entirely different ways to send a refusal back through MCP — as a JSON-RPC error, or as a tool result flagged as an error. They land in front of different readers, and choosing the wrong one is how a model ends up retrying a call that was never going to succeed.

Tools over MCP — read the current version at https://daviddominguez.dev/learn/en/tools-over-mcp/enforcement-in-the-tool-handler

Back to top