How do you secure Model Context Protocol tool servers?

MCP tool server security

Secure MCP by putting a governed endpoint between your agents and every upstream tool server, and then getting four things right at that endpoint: re-derive authorisation from the credential on every single request rather than from a session; re-check the grant when a tool is called rather than trusting that it came off a filtered list; hash each tool descriptor at approval and quarantine it when it drifts, because a tool’s name, description and input schema are part of the model’s instruction surface; and inspect arguments and results under hard bounds that fail closed rather than forwarding a tail you did not read. The two failures that make MCP different from an ordinary API integration are both about trust in the wrong direction. A tool server can rewrite its own descriptions and steer an agent without a line of your code changing, which is the supply-chain risk; and a tool result is attacker-authored text that the model reads as instruction, which is the injection channel that actually hijacks agents. Add to that the mundane and dangerous surface of registering a server at all — a row naming a URL and an environment variable whose value is sent as a credential to that URL — and the security work becomes clear.
Authorisation lifetime
Re-derived from the credential on every request; sessions carry no permissions
List versus call
Filtering the catalogue is usability; the call re-checks the grant independently
Descriptor integrity
SHA-256 over the canonical name, description and input schema, pinned at approval
Inspection bounds
Depth 32, 5,000 nodes, 65,536 characters per string and in aggregate — exceeding any fails closed
What is withheld rather than inspectedImage, audio, blob and resource content, after the tool has already run
On this page
TOOL SURFACE

Three risks that are specific to a tool protocol

The first is that the descriptor is instruction surface. A tool’s name, its description and its input schema are all fed to the model to help it decide what to call and how, which means an upstream server that quietly rewrites a description can steer an agent without anything in your code changing and without a single anomalous request. This is the rug-pull shape of supply-chain attack, and it is materially different from a compromised dependency because there is no artefact in your build to scan.

The second is that the tool result is attacker-authored text. Whatever the tool returns re-enters the model’s context, and the model does not reliably maintain the distinction between data and instruction. A tool that reads a ticket, fetches a page or queries a table is a channel from whoever wrote that content into the agent’s reasoning. Any defence that inspects only what a user typed misses this entirely, and it is where real hijacks come from.

The third is the least glamorous and the most likely to bite: registering a server is a privileged act that does not look like one. An MCP server row names a URL and an environment variable whose value gets sent as an Authorization header to that URL. In Token Observe that row is writable at operator rank rather than admin, and the outbound call is triggered by any viewer listing that server’s tools. Unguarded, that primitive reads any variable in the process — the session secret, the audit key, cloud credentials, every provider key — and posts it anywhere on the internet. It is the most powerful thing in the whole tool subsystem and it is shaped like a configuration form.

Two further properties of the protocol shape the design. Tool calls are proposed by a model rather than by a programmer, so the argument values are as attacker-influenceable as the prompt. And the catalogue is dynamic: servers can add, remove and change tools between requests, so anything cached has to be re-verified rather than trusted for the life of a process.

Put one governed endpoint in front, and make sessions carry nothing

The architecture that works is a gateway that is itself an MCP server to the agent fleet and an MCP client to every registered upstream. Agents connect to one endpoint; the gateway presents the union of the upstream catalogues, namespaced so that a tool from one server cannot collide with a tool from another, and decides every call.

Namespacing needs a little care to stay legal. Prefixing a server name onto a tool name has to produce a name the protocol still accepts, so reserve the separator character in server names and cap the combined length — Token Observe restricts server names to 64 characters of letters, digits, underscore and hyphen, reserves the dot as the separator, and caps the namespaced result at 128 characters.

The decision that pays for itself is to re-derive authorisation from the credential on every single request, and to let sessions exist only for routing and resuming server-to-client streams. A session that carries permissions is a permission set with a lifetime nobody manages: revoke a grant and the open session keeps using it until something reconnects. Re-deriving per request also means a stateless dialect can be added later as a second dialect on the same endpoint rather than as a rewrite.

Negotiate the protocol version explicitly and support a bounded set. Token Observe targets revision 2025-11-25 and negotiates down to 2025-03-26, with 2024-11-05 deliberately absent because it predates the current transport and would require a legacy shim. An unbounded compatibility surface is an unbounded attack surface, and there is no prize for accepting a version you cannot govern properly.

One consequence of the gateway shape is worth planning for on the client side: a 404 on a request carrying a session id means the session is gone and the client must re-initialise without one rather than retrying. Clients that retry blindly against a dead session produce a failure that looks like an outage and is actually a protocol handling bug.

Filtering the list is a usability feature; the call is the enforcement point

Return the tool list filtered to what the calling agent may actually use. That stops a framework picking a tool off the catalogue that will be refused three lines later, and it is the point where the permission model becomes visible to a client that knows nothing about your governance layer.

Then re-enforce the grant when the tool is called, independently, because a client can simply guess a name or remember a stale catalogue. Treating list filtering as access control is a known anti-pattern and it is easy to fall into, because in ordinary operation the filtered list and the enforced grant agree — right up until the moment somebody is probing.

Distinguish the two refusal shapes, because they carry different information and leaking the wrong one is free reconnaissance. A tool the agent cannot see should come back as an unknown-tool protocol error rather than as forbidden: telling an agent which tools exist but are off-limits is a map of your estate. A tool the agent can see but may not use right now — blocked by policy, awaiting an approval, over a rate limit — should come back as an error result the model can read and act on, carrying a machine-readable code beside the human-readable text so a well-behaved agent can react rather than retry blindly.

Record the refusal either way. A denied tool call should open a trace, record the denial with a note of whether the tool actually existed, and close as blocked. An agent working through tool names it was never granted is exactly the pattern an investigation needs to see afterwards, and it is invisible if refusals are dropped on the floor.

The same intersection rules that govern model calls apply here. Where a request names upstream agents, every hop must allow the tool call independently; an unresolvable hop should contribute an empty role set and deny through the ordinary intersection rather than being silently skipped, and the chain should be bounded — Token Observe truncates at eight hops on the tool path.

The two refusals, and why they differ
tools/call  payments.issue_refund     (agent holds no grant, tool hidden)
  -> JSON-RPC error -32602  Unknown tool
     error.data._meta { code: ACP_RBAC_DENIED, retryable: false }
     // existence and visibility collapse into one answer

tools/call  orderdb.issue_refund      (granted, but policy gates it)
  -> result isError: true
     text: a human must approve this action before it can run
     _meta { code, retryable, approvalId }
     // the model can read this and stop, rather than retrying

Pin the descriptors, and quarantine on drift

Because the descriptor is instruction surface, treat a change to it as a security event rather than as an update. The mechanism is straightforward: hash the canonicalised name, description and input schema at the moment an operator approves the tool, then re-hash on every catalogue refresh and compare.

Three states fall out of that, and the interesting decisions are about what each one does. A tool whose hash matches its pin is approved and usable. A tool with no pin at all is usable and surfaced for approval — deliberately not blocked, because a gateway that refused every unreviewed tool would simply not be adopted, and an unadopted control protects nothing. A tool whose hash differs from its pin is quarantined immediately: hidden from the catalogue, refused on call, and the drift recorded and alerted. The asymmetry is the point. Unreviewed is a workflow state; changed-after-approval is somebody moving the instruction surface underneath you.

Canonicalisation matters more than it sounds. Two descriptors that differ only in the order of their JSON keys have to hash identically, or every refresh reports drift and the alert becomes noise within a day — after which nobody reads it, which is the failure the mechanism exists to prevent.

Cache with a short lifetime and re-verify on expiry rather than trusting a snapshot for the life of the process. Token Observe re-verifies descriptors after 60 seconds. Pair that with a change notification to connected sessions so a client is told the visible catalogue has changed, rather than discovering it on the next failed call.

Bound what an upstream can do to you while you are listing. A hostile or broken server can paginate forever or return an unbounded catalogue; caps on pages and on tools per server turn that from a hang into an error. Token Observe stops at 50 pages and 2,000 tools per server, with explicit timeouts on every upstream call and retries only on idempotent operations.

pinned
The descriptor hash matches what an operator approved. Usable, and the state you want the estate to be in.
unpinned
No approval recorded yet. Usable and surfaced for review — a deliberate product decision, because a gateway that blocked every unreviewed tool would be routed around rather than adopted.
quarantined
The hash changed, or an operator quarantined it. Hidden from catalogues, refused on call, with the drift recorded and alerted. An approved thing changing underneath you is a security event, not an update.

Inspect arguments and results under bounds that fail closed

Everything crossing this boundary is untrusted in both directions: arguments are proposed by a model that reads attacker-influenced text, and results are written by systems you may not control. Both need the same treatment as a prompt — normalise the text, then scan it for sensitive data and for injection — and both need bounds, because inspection is inline work on attacker-controlled input.

The bounds have to be explicit, and exceeding one has to fail closed rather than truncating. Token Observe inspects descriptor descriptions, schemas, argument trees and result trees across both JSON keys and values under a hard depth of 32, a node limit of 5,000, a per-string limit of 65,536 characters and an aggregate detector-input limit of the same, and exceeding any bound refuses rather than forwarding an uninspected tail. That last clause is the important one: a deeply nested payload that silently skips the part you did not walk is a hole shaped exactly like the thing an attacker would build.

Withhold a descriptor that itself contains injection or secrets rather than presenting it with a warning, because the model is the thing that will read it. And withhold content types you cannot inspect. Token Observe passes through text blocks and structured JSON, and withholds image, audio, blob and resource content after the tool returns, because it has no bounded media decoding or optical character recognition — which means a picture of a prompt is a channel it cannot read.

That refusal has an unusual property worth designing for, and it is the honest bit. The tool has already run. A withheld result is not a prevented action; it is a completed action whose output is not being shown. So the refusal has to be terminal and it has to say so explicitly, because an agent that reads a refusal as retryable will run the side effect again. Anything that returns a soft error where the effect already committed is manufacturing duplicate refunds.

The same reasoning is why a tool call worth gating deserves more than a policy check. Where an action is irreversible, the useful construct is a bounded contract around the call — a declared expectation of what committing looks like, verified externally afterwards, with a defined compensation or manual-review path when verification fails. Token Observe’s effect contracts are deliberately not an expression language: they select JSON values, compare them with six bounded operators and copy them into pinned tool arguments, and they cannot run code or interpolate a template. A contract that could execute arbitrary logic would be a second injection surface sitting inside the control.

The registration form is the most privileged screen in the system

An MCP server row names a destination and a credential. Treat both as egress controls that refuse rather than warn, because unguarded that pair is read any environment variable and post it anywhere.

Constrain the destination with a host allowlist, and pick a default that is safe rather than convenient. Token Observe’s default is loopback only, on the reasoning that a sidecar is the one topology it is safe to assume — anything else, whether an internal tool server on the estate network or a vendor’s hosted endpoint, is a destination an operator has to name deliberately, because somewhere on your network and somewhere on the internet are the same string to a process making an outbound call.

Constrain the credential with a prefix allowlist on the environment-variable name, and keep the namespaces disjoint. Token Observe keeps four — provider keys, MCP credentials, webhook credentials and vendor-admin credentials — so no integration can name another’s secret, and none can name the session secret. An empty allowlist has to mean nothing rather than everything, which is a trap that has caught more than one implementation: the natural reading of an unset list is any, and any is the vulnerability.

Do not echo back what you were given. Store the full URL and return only its origin, keeping userinfo, path, query and fragment write-only, so a credential smuggled into a URL cannot be read back out through the API that stores it. On update, either preserve the stored destination or take a complete replacement rather than merging fragments, and provide an explicit way to remove a credential reference rather than leaving a dangling one.

Then bound the client side. Refuse redirects rather than following them, so a server cannot bounce your credentialed request somewhere else. Put an explicit timeout on every call. Retry only idempotent operations, with backoff and jitter. Bound the response body and each streamed event independently — Token Observe caps at 32 MB and 4 MB respectively — so a hostile upstream cannot exhaust memory on a governed path.

One last note for estates with developer subscriptions in them. Pinning a coding assistant’s client, through a managed-settings channel the developer cannot remove, to allow exactly one MCP server — yours — is the cheapest real enforcement available for a seat, because every tool call the editor makes then lands on a route your evaluator decides. Be equally clear about what it does not cover: the client’s built-in tools, such as shell commands and file edits, and its model traffic do not come through that endpoint. Anything else is a claim that will not survive the first demo.

in practice

How to put MCP tool server security into practice

  1. 01

    Route every tool call through one governed endpoint

    Point agents at a single MCP endpoint that fronts the upstream servers, namespaced per server. Direct client-to-server connections are ungoverned by construction, and no amount of policy elsewhere reaches them.
  2. 02

    Re-derive authorisation on every request

    Resolve the caller from the credential each time and let sessions carry no permissions at all. A session that holds authority is authority with an unmanaged lifetime, and a revoked grant should not survive in an open connection.
  3. 03

    Filter the list, and re-check on the call

    Return only the tools the caller may use, then enforce again when one is invoked. Return unknown-tool for something they cannot see and a readable error result for something they can see but may not use right now.
  4. 04

    Pin every descriptor at approval

    Hash the canonicalised name, description and input schema, and store the hash against an operator’s approval. Canonicalise properly, or key ordering alone will generate false drift and the alert will be ignored within a day.
  5. 05

    Quarantine on drift, and alert

    A descriptor whose hash no longer matches its pin should disappear from catalogues and be refused on call until somebody re-approves it. Leaving it usable with a warning hands the decision to the model, which is the thing being attacked.
  6. 06

    Inspect arguments and results under explicit bounds

    Normalise then scan both directions, with hard limits on depth, node count and string length, and fail closed when a limit is hit. Never forward the part you did not read, and withhold media you cannot inspect — saying plainly that the tool already ran.
  7. 07

    Lock down the registration surface

    Allowlist the hosts a server may be registered against, allowlist the environment-variable prefixes a credential may come from, keep those namespaces disjoint from every other integration, return URLs origin-only, refuse redirects, and bound every response body.

Is filtering the MCP tool list enough to control what an agent can call?

No, and treating it as access control is a known anti-pattern. A client can guess a tool name, or remember a catalogue from before a grant was revoked, so the call itself has to re-check the grant independently of whatever the list said. Filtering is still worth doing as a usability feature: a framework that picks a tool off the catalogue cannot pick one that will be refused three lines later. The two refusal shapes should differ — unknown-tool for something the agent may not see, so existence is not leaked, and a readable error result for something it may see but may not use right now.

How do you defend against a tool server changing its own descriptions?

By hashing the descriptor at approval and comparing on every refresh. A tool’s name, description and input schema are part of the model’s instruction surface, so an upstream that rewrites them can steer an agent without anything in your code changing — the rug-pull shape of supply-chain attack. A hash that matches its pin is approved and usable; a hash with no pin is usable and surfaced for review, because blocking every unreviewed tool gets the gateway routed around; a hash that differs is quarantined immediately, hidden from catalogues and refused on call. Canonicalise before hashing, or key reordering generates drift alerts nobody will read.

What happens to a tool result that cannot be fully inspected?

It is withheld, and the refusal has to be terminal and explicit about why. Token Observe passes through text blocks and structured JSON, and withholds image, audio, blob and resource content after the tool returns, because it has no bounded media decoding or OCR — so a picture of a prompt is a channel it cannot read and will not forward. The same applies when a payload exceeds the inspection bounds on depth, node count or string length: it fails closed rather than forwarding an uninspected tail. The critical detail is that the tool has already run, so the message says so — an agent that reads a withheld result as retryable will repeat the side effect.

Why is registering an MCP server a privileged action?

Because the row names a URL and an environment variable whose value is sent as an Authorization header to that URL, which unguarded is a primitive that reads any variable in the process and posts it anywhere. In Token Observe that row is writable at operator rank and the outbound call is triggered by any viewer listing the server’s tools, so the two allowlists — permitted hosts, and permitted environment-variable prefixes — are the controls that make the form safe. The default host list is loopback only, because a sidecar is the one topology it is safe to assume; everything else is a destination somebody has to name.

Does governing MCP cover a developer’s coding assistant?

Partly, and the boundary is worth stating before anyone assumes otherwise. Pinning the client through a managed-settings channel the developer cannot remove, so that exactly one MCP server is allowed, means every tool call the editor makes lands on your endpoint and is decided by the same evaluator against the same rules — which is the cheapest real enforcement available for a subscription seat, needing no hook and no cooperation from the vendor’s runtime. What it does not cover is the client’s built-in tools, such as shell commands and file edits, and its model traffic. Those need a different mechanism, and what reaches your governance layer about them afterwards is telemetry rather than enforcement.

Ask about this guide
Ask anything about the subject. These guides are written to be useful whether or not you ever buy anything, and this answers in the same spirit.

Prefer to ask a person? Write to us →

get in touch

Bring us the question this guide did not answer.

Write to hello@tenhaw.com with what your agents do, which providers they call and what would have to be true for you to put something in front of them. James Rooney replies. You will get a straight answer about whether Token Observe fits, including when it does not.

no form · no qualification step · no sales desk · the other three ways in