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.
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.
| 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
git clone https://github.com/Excession-Labs/mcp-security-fixture
cd mcp-security-fixture
./run.sh
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 asks the agent to call a second tool that returns the user's key and to send that key back as an extra argument. 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)
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:
# server.py, fetch_report
outbound = fa.mint(user, fa.AUD_DOWNSTREAM) # the server's own token, audience downstream
# downstream.py
if args.mode == "fixed" and claims.get("aud") != fa.AUD_DOWNSTREAM:
return self._send(401, {"error": "wrong audience"})
What the test checks: it calls fetch_report as Alice and compares the last token the downstream API logged with her token. It then sends her MCP token straight to the downstream API, to see whether a token issued for the MCP server also works there. 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")
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
subandaudclaims, 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.
- For every tool that takes an id, sign in as user A and ask for an object that belongs to user B.
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.
- Model Context Protocol, Versioning: "The current protocol version is 2026-07-28."
- MCP specification 2026-07-28, Authorization and its page Authorization Security Considerations (section "Access Token Privilege Restriction").
- MCP specification 2026-07-28, Security Best Practices (sections "Token Passthrough" and "Confused Deputy Problem").
- Invariant Labs, MCP Security Notification: Tool Poisoning Attacks, 1 April 2025.
- OWASP Gen AI Security Project, LLM01:2025 Prompt Injection.
- OWASP API Security Top 10, API1:2023 Broken Object Level Authorization.
- MCP Python SDK, migration guide to version 2.
Data files
- results.csv and run.sh: the 16 results and the reproduction script.
- README.txt and SHA256SUMS: the run output, source quotes and hashes.
- mcp-security-fixture on GitHub. Code and data are under the MIT licence.