Skip to content

Security·9 min read

MCP Servers in Production: How We Vet and Lock Them Down

Inventory every MCP server first, authenticate each caller with a token issued for that exact server, sandbox anything that runs locally, and log every tool call. Most 2026 MCP incidents trace back to configuration and defaults, and the 28 July 2026 spec revision adds a migration on top.

Anatoli NavahrodskiFounder & CEO, GlanitPublished 16 October 2026

What actually went wrong with MCP servers in 2026?

One pattern keeps coming back. With the STDIO transport, the configuration that names a server is also the command that starts it, so whoever can write the config can run a process. On 15 April 2026 OX Security showed that the official MCP SDKs (Python, TypeScript, Java, Rust) pass configuration-supplied commands through StdioServerParameters to the operating system without sanitising them. They counted more than 7,000 publicly reachable servers and estimated up to 200,000 vulnerable instances. That second figure is an estimate. Anthropic declined to change the architecture; per OX it called the behaviour expected and left sanitisation to the developers who build clients.

Three incidents from the last twelve months show the shapes this takes:

  • Config field to code execution. Flowise's CustomMCP node executed JavaScript from a user-supplied MCP server config (CVE-2025-59528). OWASP's Q1 2026 round-up records active exploitation from 7 April 2026, with 12,000 to 15,000 instances facing the internet. Version 3.0.6 fixed it.
  • A repository that starts servers on its own. Check Point Research published on 25 February 2026 that repository-level settings in Claude Code could start MCP servers before the user confirmed trust (CVE-2025-59536), and a second flaw sent the API key to an attacker-chosen endpoint (CVE-2026-21852). Anthropic fixed both before disclosure.
  • Registries. In the same OX research, 9 of 11 MCP registries accepted a test package that OX calls a "malicious trial balloon".

The uncomfortable part is in the OWASP report: most AI security events in Q1 2026 had no CVE at all, and misconfiguration (overprivileged agents, for one) heads its list of causes. A CVE feed alone will miss most of what can hurt you here.

Build our own MCP server or adopt a public one?

Our default is to write a thin MCP server ourselves, in Python or Node, around an API we already control and already secure. Public servers get adopted only when they are pinned to an exact version, their source has been read for that version, and they run inside a sandbox. Building costs us maintenance, and the protocol does move: Python SDK v2.0.0 shipped on 28 July 2026, renamed FastMCP to MCPServer, and put v1 into security-fixes-only mode.

How we decide, per integration:

  • Adopt a remote server that the vendor runs for its own API, with OAuth and scopes narrow enough to issue read-only tokens.
  • Adopt with conditions a community package: exact version, lockfile hash, source read, container, no network unless it needs one.
  • Build when the tools touch our client's data or any production write path, or when a public server exposes dozens of tools and the agent needs three.
  • Refuse anything launched as npx -y some-server with no version. That line installs whatever the registry serves on the day of the run.

One habit we had to drop: treating a widely installed server as half-vetted. Popularity is not an input in our review any more. The postmark-mcp package on npm behaved like an ordinary email tool until version 1.0.16 began silently BCC'ing every outgoing message to an outside address (Snyk, 25 September 2025). Earlier versions were clean, which is exactly why version pinning matters more than reputation.

Inventory first, because there is always a shadow server

Before any other control we write the list. For each server: owner, transport, SDK and version, auth method, credentials it holds, tools it exposes, read or write. The first finding in every review is a server nobody listed, usually added by a developer to a local IDE config. OWASP calls it MCP09:2025, Shadow MCP Servers. Each entry is approved once and re-approved on any change, including a change in tool descriptions.

A record looks like this (YAML, kept next to the code that calls the agent):

- name: billing-readonly
  owner: payments tech lead
  transport: streamable-http
  url: https://mcp.internal.example/billing
  sdk: mcp (Python) 2.3.0
  protocol: [2026-07-28, 2025-11-25]
  auth: oauth2.1, audience https://mcp.internal.example/billing
  scopes: [invoices:read]
  credentials_held: none, per-user tokens only
  tools: [get_invoice, list_overdue]
  tools_list_sha256: 9f2c41d0...e41a
  writes: false
  approved: 2026-10-02

The tools_list_sha256 line is the hash of the normalised tools/list output. Tool poisoning (MCP03:2025) works by changing what a description tells the model, so a changed hash sends the server back to review before the agent sees it again. The 2026-07-28 spec says as much: tool annotations are untrusted unless they come from a trusted server.

Not every server deserves the same treatment. Read the right-hand column of the table below as the minimum we accept before an agent gets the server; the left two columns explain why it differs per row.

MCP server risk by type

MCP server risk by type
Server typeTypical riskOur default control
Local STDIO server run by a developerConfig becomes a command; runs with the developer's full privilegesContainer, read-only mounts, credentials from env scoped to this server only
Public remote server (third party)Tool poisoning, over-broad scopes, data leaving to a vendor we have not assessedRead-only scopes, pinned tool-description hash, allowlisted per agent
Remote server we host for internal APIsShared service key, token pass-through, unauthenticated exposureOAuth 2.1 resource server, audience check, private network
Server with write access to production systemsOne injected instruction becomes a real changeHuman approval per write, step-up scopes, full audit trail
Registry-installed packageMalicious or hijacked releaseExact version and hash, source read, re-review on upgrade
MCP server risk by type

How should an MCP server authenticate callers?

A remote MCP server on HTTP is an OAuth 2.1 resource server. The 2026-07-28 authorization spec says it MUST publish Protected Resource Metadata (RFC 9728), MUST validate that each token was issued for it as the audience, and MUST NOT accept or pass on any other token. Clients MUST send the RFC 8707 resource parameter naming the server, and tokens MUST NOT travel in the query string. STDIO servers sit outside this: the spec says they SHOULD NOT use the OAuth flow and take credentials from the environment instead.

The first unauthenticated call should get this, straight from the spec:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

In practice that means four rules. Tokens are per user, so the server acts with that person's rights. Scopes start minimal, and a write asks for more through a 403 insufficient_scope step-up, which the spec describes. There is no shared service key behind the server. And the server rejects a token minted for anything else, which is the defence against the confused deputy problem the spec's security best practices page spells out.

Check the audience setting in your SDK. TypeScript SDK 2.3.0 (2 October 2026) added expectedResource to requireBearerAuth, and its release notes say it is off unless you set it.

STDIO servers and config files are code

A local STDIO server is a process with the privileges of whoever launched it, so we run it in a container: read-only mounts for the project, --network none unless the tool needs a host, and then an egress allowlist naming that host. Credentials go in through environment variables, one per server, never a developer's personal cloud profile.

The files that start these servers (.mcp.json, editor MCP configs, .claude/settings.json) go through code review like any script, with a CODEOWNERS entry. Claude Code's documentation now states that a cloned repository can't approve its own servers. We still never set enableAllProjectMcpServers or any equivalent blanket approval. The Check Point findings were exactly this class of problem: repository config deciding what runs before a person agrees.

Tool output is someone else's input

Every tool result is untrusted data, however internal the server. A ticket body or a fetched web page can carry instructions, and the model has no reliable way to tell them apart from yours. DuneSlide made that concrete: in Cursor before 3.0, a benign prompt that pulled in injected content could make the agent set working_directory outside the workspace and overwrite the sandbox helper (CVE-2026-50548, published on NVD on 25 June 2026, CVSS 4.0 score 9.3, fixed in 3.0).

Our rule follows Simon Willison's "lethal trifecta": private data, untrusted content and a way to send data out. One agent never gets all three. If it reads customer records and also reads inbound email, it gets no outbound HTTP tool and no email-send tool. Writes go through a human approval step that shows the exact arguments the tool will receive.

The general agent controls are in Securing AI agents in production, and the LLM side of injection in our OWASP Top 10 for LLM applications article. This section only covers what changes when the content arrives through MCP.

28 July 2026: the spec went stateless

The 2026-07-28 revision removed protocol-level sessions and the Mcp-Session-Id header, dropped the initialize handshake (each request now carries its protocol version and capabilities in _meta), and made server/discover mandatory for servers. It also removed ping, tightened OAuth issuer validation and documented W3C trace context propagation. Stacklok's readiness guide is blunt about the consequence: modern-only clients do not automatically work with legacy-only servers.

The deprecation policy has a catch. The minimum twelve-month window covers features marked Deprecated (Roots, Sampling, Logging, the old HTTP+SSE transport, Dynamic Client Registration). Removed features get no window. A server that depends on 2025-11-25 sessions has no year of grace. Its deadline is the day its clients go modern-only.

Our order for servers already running:

  1. Take the inventory above and add the protocol version each server speaks.
  2. Classify each one by what it uses: removed features, deprecated features, or neither.
  3. Upgrade the SDK. Python mcp 2.x serves 2026-07-28 and every 2025-era client from one server; teams not ready can pin mcp>=1.28,<2 and get security fixes only.
  4. Test with session affinity switched off at the load balancer. Sticky sessions hide state bugs that the new spec makes fatal.
  5. Roll out one server at a time, behind a flag, with the old build kept deployable.

Steps 1 and 2 usually turn up the shadow servers again.

What do we log for every tool call?

Per call: the user and the client ID, server, tool, a hash of the arguments (the arguments themselves only when they hold no personal data), result size, the decision (allowed, denied, or approved and by whom) and the trace ID. OWASP lists the lack of this as MCP08:2025, Lack of Audit and Telemetry. The new spec helps with the last field: traceparent travels in _meta, and the Python SDK v2 ships OpenTelemetry tracing on by default.

For agents that touch money or personal data, the log has to survive an attacker who gets into the host, so we write it to tamper-evident storage. How that works is in SealedRun: tamper-evident audit trails for AI agents.

Next time we would put MCP servers in the scope of the first security test, by name, instead of a later round. A penetration test that knows the token audience and the write tools finds things a generic web-app scope walks past. If you want a second pair of eyes on your own list, our services page is where to start.

Frequently asked questions