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-serverwith 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-02The 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
| Server type | Typical risk | Our default control |
|---|---|---|
| Local STDIO server run by a developer | Config becomes a command; runs with the developer's full privileges | Container, 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 assessed | Read-only scopes, pinned tool-description hash, allowlisted per agent |
| Remote server we host for internal APIs | Shared service key, token pass-through, unauthenticated exposure | OAuth 2.1 resource server, audience check, private network |
| Server with write access to production systems | One injected instruction becomes a real change | Human approval per write, step-up scopes, full audit trail |
| Registry-installed package | Malicious or hijacked release | Exact version and hash, source read, re-review on upgrade |
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:
- Take the inventory above and add the protocol version each server speaks.
- Classify each one by what it uses: removed features, deprecated features, or neither.
- Upgrade the SDK. Python
mcp2.x serves 2026-07-28 and every 2025-era client from one server; teams not ready can pinmcp>=1.28,<2and get security fixes only. - Test with session affinity switched off at the load balancer. Sticky sessions hide state bugs that the new spec makes fatal.
- 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.