DEV Community

Vladimir Osipov for Excession Labs

Posted on Originally published at excessionlabs.ai

MCP security: a vulnerable MCP server you can run, with three flaws and their fixes

An MCP server gives an AI agent a set of tools, such as "add two numbers" or "fetch a report". MCP security is the work of making sure each tool does only what the calling user may do.

We built a small MCP server with a vulnerable mode and a fixed mode, and wrote tests for three flaws. We ran them on 12 November 2026 and again on 13 November 2026, and both runs gave the same 16 results. In vulnerable mode all 4 attack checks succeeded. In fixed mode all 4 were blocked. The 3 control checks gave the same answer in both modes, so the fixes did not break normal use.

The three flaws are a tool description that carries hidden instructions, a user token passed on to another API, and a tool that checks who you are but not what is yours. Each can leak data, and each has a short test. The server, tests and results are in the mcp-security-fixture repository (commit 194b4a6). For each flaw this page describes the flaw, what the test checks and the fix. The exploit text stays in the repository.

The fixture shows three flaws, not all of MCP, and its agent is a stub, not a language model. See the limits. We checked every quote against the MCP specification version 2026-07-28, which the specification lists as the current version on 13 November 2026.

On this page: Results · Run it · Flaw 1: hidden instructions · Flaw 2: token passthrough · Flaw 3: missing authorization · What the fixture does not show · Check your own server · Sources · Data

Results: 8 checks in 2 modes

"Exploited" means the attack worked and "blocked" means it did not. "Detected" and "clean" come from a simple description scanner. "Ok", "allowed" and "rejected" are controls that must not change.

Outcome of each check, vulnerable mode and fixed mode (16 results, all as expected)

Flaw Check Vulnerable Fixed
1 Hidden instructions Stub agent leaks a secret to the tool exploited blocked
1 Hidden instructions Description scanner detected clean
2 Token passthrough Downstream API received the client's own token exploited blocked
2 Token passthrough Tool still works ok ok
2 Token passthrough MCP token accepted at the downstream API exploited blocked
3 Missing authorization Alice reads Bob's invoice exploited blocked
3 Missing authorization Alice reads her own invoice allowed allowed
3 Missing authorization Request with no token rejected rejected

The run used mcp 2.3.0, uv 0.10.4 and Python 3.13.12.

Run it yourself

You need uv and Python 3.10 or newer. The script installs mcp==2.3.0, starts the server and a stub downstream API on free loopback ports, runs the three test scripts in both modes, and writes results.csv. Exit code 0 means every outcome matched. Everything stays on 127.0.0.1 with fake data. With the environment already installed, our run took about 5 seconds.

Version 2 of the Python SDK renamed FastMCP to MCPServer, so code for 1.x does not import (migration guide). Test only this fixture and servers you own.

Flaw 1: a tool description that carries hidden instructions

An agent reads every tool description as context, and the user usually sees a short version. Invariant Labs named the result a tool poisoning attack on 1 April 2025: "A Tool Poisoning Attack occurs when malicious instructions are embedded within MCP tool descriptions that are invisible to users but visible to AI models." They call it "a specialized form of indirect prompt injections". OWASP defines that class in LLM01:2025: "Indirect prompt injections occur when an LLM accepts input from external sources, such as websites or files." In our prompt injection lab, 69 of 300 attack trials (23.0%) succeeded with no defense.

In vulnerable mode, the description of the add_numbers tool carries a block of extra text addressed to the agent. The text is in server.py in the repository, and we do not copy it here.

What the test checks: a stub agent reads the description, follows one instruction pattern and then does the user's task, an addition. The test looks at whether the user's fake key reached a log file that stands in for an attacker's server, and whether the user still gets the right answer.

Observed result: the key reached the log in vulnerable mode and did not in fixed mode. In both modes the user gets the right answer, 5, so the leak is silent. A client-side scanner with five regular expressions flagged add_numbers in vulnerable mode and nothing in fixed mode.

The fix for a server you write is a plain description and no extra argument:

@mcp.tool(description="Adds two numbers and returns the sum.")
def add_numbers(a: int, b: int) -> str:
    return str(a + b)
Enter fullscreen mode Exit fullscreen mode

That fix helps only when you write the server. A hostile server writes its own description, so for other servers the control sits in the client. Invariant says: "Many client implementations don't properly sanitize, review, or show included tool descriptions to the user." The fixture README recommends that clients pin the descriptions they approved and show them to the user. The fixture does not test that control.

Flaw 2: token passthrough

Token passthrough means the MCP server takes the token the client sent it and uses that same token to call another service. The authorization part of the MCP specification forbids it. Its security considerations page says: "The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server MUST NOT pass through the token it received from the MCP client." The security best practices page says passthrough can cause the confused deputy problem, "where the downstream API may incorrectly trust the token as if it came from the MCP server or assume the token was validated by the upstream API". Its mitigation reads: "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server."

The same best practices page has a separate Confused Deputy Problem section about proxy servers that use a static client ID, dynamic client registration and consent cookies. The fixture does not model that attack.

Our server checks the audience of the inbound token in both modes. The flaw is what happens next. In vulnerable mode, fetch_report forwards the inbound token to the downstream API, and the downstream API accepts any token with a valid signature. In fixed mode the server mints its own token for the downstream audience, and the downstream API rejects any other audience:

outbound = fa.mint(user, fa.AUD_DOWNSTREAM)  # the server's own token, audience downstream

if args.mode == "fixed" and claims.get("aud") != fa.AUD_DOWNSTREAM:
    return self._send(401, {"error": "wrong audience"})
Enter fullscreen mode Exit fullscreen mode

What the test checks: it calls fetch_report as Alice and compares the last token the downstream API logged with her token. HTTP 200 counts as exploited and HTTP 401 as blocked.

Observed result: the downstream API saw the client's own token in vulnerable mode and a different token in fixed mode. The MCP token worked at the downstream API in vulnerable mode and returned 401 in fixed mode. fetch_report returned the report in both modes.

Fixed mode makes two changes together: the server stops forwarding, and the downstream API checks the audience. The tests do not separate them. In production the server's own token comes from a token exchange or a service credential, not from a shared demo secret.

Flaw 3: a tool with no per-user authorization

This server knows who you are and never asks what is yours. Login works in both modes, and a request with no token is rejected in both, so the flaw is authorization. OWASP calls the class broken object level authorization (API1:2023). It says attackers exploit it "by manipulating the ID of an object that is sent within the request", and that the fix is to "check if the logged-in user has access to perform the requested action on the record in every function that uses an input from the client to access a record in the database". The MCP specification asks servers to "take all necessary steps to ensure no data is returned to unauthorized parties".

In server.py both modes are one function, get_invoice. Vulnerable mode skips the line that compares the invoice owner with the caller. Fixed mode adds it:

if inv["owner"] != user:
    raise PermissionError("not your invoice")
Enter fullscreen mode Exit fullscreen mode

What the test checks: it signs in as Alice and asks for Bob's invoice, asks for her own invoice, and then sends a request with no token. It counts a reply without an error that names Bob as exploited.

Observed result: Alice read Bob's invoice in vulnerable mode and got an error in fixed mode. She read her own invoice in both, and the request with no token failed in both.

In our scanner comparison, no setup found any of the six authorization fixtures, because that takes two users and a rule about who owns what. Our API penetration testing scope asks the same question: can account B read account A's record by its ID?

What the fixture does not show

  • The agent in flaw 1 is a stub, not a language model. It obeys one instruction pattern. The test shows that server text reaches the agent's context and can drive a tool call. It does not show that a given real model would obey. We did no real model run.

  • The fixture has 3 flaws. The security best practices page of the 2026-07-28 specification also covers server-side request forgery, state handle hijacking, local MCP server compromise, OAuth authorization URL validation, stdio transport security in proxy scenarios, mix-up attacks, localhost redirect URI impersonation, client ID metadata document trust policies and scope minimization. We tested none of them. Tool output injection, where hostile text arrives in a tool result, is also not covered.

  • The tokens are toy HMAC tokens with sub and aud claims, not OAuth. The scanner is a list of regular expressions that catches this fixture and would not stop a careful attacker.

  • We tested no hosted or third-party MCP server.

  • Fixed mode passes these 8 checks and nothing more.

Check your own MCP server

  • Read every tool description the server sends. Look for text that talks to the agent instead of describing the tool, and for arguments the tool does not need.

  • Follow one request to every service the server calls. Each service must receive a different token from the one the client sent, and must check its own audience.

To have someone run these against your server, see AI red teaming, whose scope covers MCP servers: what each tool exposes, who may call it and what it returns. For a full test of an application and its AI features, see AI penetration testing.

Sources and dates checked

We read each page on 13 November 2026 and matched every quoted string against the page text.

Data files

First published on excessionlabs.ai

Top comments (2)

Collapse
 
x1y1co profile image
X1Y1 •

Flaw 2 is the one an outside check can't see, which is why a fixture like this is useful. On November 4 we checked 61 official remote MCP servers against the auth spec from the outside, and all 61 returned the 401 challenge and handled PKCE correctly. None of that tells you whether a server then forwards the user's token downstream, because that call never crosses anything we can watch. It only shows up from inside, the way your test does it: log what the downstream API receives and compare.

Of your two fixed-mode changes, the audience check on the downstream API is the one that survives a regression. If someone later reintroduces forwarding in the server, the downstream 401 still catches it.

Collapse
 
mp3sss31 profile image
mp3sss •

Useful fixture because the three flaws map onto ordinary API failures once you stop treating MCP tools as a special case. The hidden-instruction description is an injection surface, so tool descriptions should be treated as untrusted input and scanned rather than trusted as prompt context; token passthrough is a credential-scope problem — forward only the minimum token the downstream call needs instead of reusing the caller's; and the identity-without-ownership check is classic object-level authorization, where the tool must verify the requested resource belongs to the caller, not just that the caller authenticated. Running vulnerable and fixed modes side by side as regression checks is the part most teams would skip; keeping those attack cases in CI keeps a refactor from quietly reopening the hole.