
MCP server penetration testing breaks into five passes. Enumerate tools, resources, and prompts, and treat their descriptions as untrusted instructions. Then check transport, test tool poisoning and rug pulls in the real client, audit OAuth audience binding and token passthrough, and prove confused-deputy behavior where the server proxies a third-party API. A tools/call fuzz that leaves the agent host out of scope misses the bugs that move data.
The short answer
- Run five passes: enumeration, transport, metadata attacks in the client UI, OAuth audience binding, and confused-deputy checks at the proxy.
- Treat tools/list, resources/list, and prompts/list descriptions as attacker-controlled instructions, and keep the agent host in scope.
- The MCP authorization spec forbids token passthrough and requires RFC 8707 audience-bound tokens. OAuth on the wire still leaves a confused deputy possible.
- Report poisoning and injection as successes over repeated trials, and name the client build, model, and temperature.

Key takeaways
- Run five passes: enumeration, transport, metadata attacks in the client UI, OAuth audience binding, and confused-deputy checks at the proxy.
- Treat tools/list, resources/list, and prompts/list descriptions as attacker-controlled instructions, and keep the agent host in scope.
- The MCP authorization spec forbids token passthrough and requires RFC 8707 audience-bound tokens. OAuth on the wire still leaves a confused deputy possible.
- Report poisoning and injection as successes over repeated trials, and name the client build, model, and temperature.
- This plan is written against MCP revision 2025-06-18. Record the revision the deployment claims and retest if it is newer.
How do I pentest an MCP server before connecting it to our agents?
Run five passes before any production agent may call the server. Keep the agent host in the statement of work.
Pass one stores every tools/list, resources/list, and prompts/list response and treats that metadata as untrusted input. Pass two checks transport: TLS on Streamable HTTP, Origin validation, DNS-rebinding resistance, and entropy of the Mcp-Session-Id header.
Pass three drives tool poisoning, rug pulls, and cross-server shadowing through the approval UI users see. Pass four audits the OAuth 2.1 profile: PKCE (RFC 7636), RFC 8707 Resource Indicators, RFC 9728 Protected Resource Metadata, and dynamic client registration (RFC 7591) where it is enabled.
Pass five looks for confused-deputy behavior anywhere the server calls an upstream API with a credential that was not issued for that hop on the user’s behalf.
MCP Inspector covers manual JSON-RPC. Invariant Labs’ mcp-scan flags hostile phrasing in descriptions before you spend model time. mitmproxy or Burp sits on Streamable HTTP and the OAuth redirects. A short local client repeats the same call so the report can show a success rate. Read the JSON-RPC bodies and, on Streamable HTTP, the optional SSE frames that carry server messages.
Scope the engagement to three parties: the agent host, the MCP server, and the upstream API or identity provider. Poisoning, rug pulls, and shadowing are demonstrable end to end only with the client in scope. A server-only writeup is easy for a vendor to dismiss. We covered that client gap in what a standard pentest still misses on AI agents.
Why does an MCP server break a normal API pentest playbook?
The protocol ships an instruction channel a REST or OpenAPI test never sees: description strings the model is told to obey.
MCP is JSON-RPC 2.0, published by Anthropic in November 2024. Revision 2024-11-05 used stdio for local servers and HTTP plus server-sent events for remote ones. Revision 2025-03-26 replaced HTTP+SSE with Streamable HTTP, POST with optional SSE for server-to-client messages, and added an OAuth 2.1 authorization framework.
This plan uses revision 2025-06-18 as the baseline. That revision recast the MCP server as an OAuth resource server, required audience-bound access tokens via RFC 8707, and required RFC 9728 protected-resource metadata so clients can discover the authorization server. Record the revision string the deployment claims, and retest if it is newer.
Server primitives are tools, resources, and prompts. Client primitives are sampling, roots, and elicitation. The list methods return the metadata that becomes model context.
Simon Willison’s lethal trifecta, described in June 2025, is the right frame. A server that can read private data, ingest untrusted content, and open an outbound channel is an exfiltration path even when the tool handler is clean. Where a risk committee wants labels, map to the OWASP LLM Top 10 (2025): LLM01 prompt injection, LLM02 sensitive information disclosure, and LLM06 excessive agency. Confused deputy is CWE-441. SSRF on a fetched URL is CWE-918.
Invariant Labs showed in May 2025 that text in a public GitHub issue could pull private-repository data into a public pull request through the GitHub MCP server. GitHub did not treat that outcome as a defect in the server itself. Asana disclosed a cross-tenant data exposure in its MCP server in June 2025. In July 2025, General Analysis showed that a Supabase MCP server configured with a service_role key could be induced to read a SQL database. Supabase positioned that server as a development tool.
First-party connectors from those vendors still produced the outcomes. The content path is the one we exercise in prompt injection testing.
How do I enumerate tools during MCP server penetration testing?
Call tools/list, resources/list, and prompts/list. Store the full JSON. Diff it on every reconnect.
Capture name, description, input schema, annotations, resource URIs, and prompt arguments. Send tools/list as a JSON-RPC request and archive each tool object, including annotations such as readOnlyHint and destructiveHint. Pagination is part of the test. A server that hides a high-privilege tool on a later page, or that moves the page boundary between calls, is already failing.
Treat annotations as claims, not facts. If a tool that writes or deletes sets readOnlyHint to true, or sets destructiveHint to false, the host may skip a warning the user needed. Compare each hint with what the handler actually does.
Hash each definition. Requiring the client to pin that hash and re-prompt on change is the rug-pull mitigation to demand. A client that keeps a stale approval is a finding against the host. Do not assume a public client already pins definitions. Test the build your users run.
Read every description as an instruction. Note text that names another server’s tools, tells the model to ignore prior policy, or asks it to send secrets to a side channel. mcp-scan is the static pass. Then confirm behavior in the live client. A sentence the model ignores is lower severity than a mild sentence it follows while the approval dialog cuts the text short.
| Transport | Where it runs | What you can intercept | Auth you should demand |
|---|---|---|---|
| stdio | Local process, newline-delimited JSON-RPC | Little on the network; the risk is the host process | None in-protocol; pin the install and drop privileges |
| Streamable HTTP | Remote POST, optional SSE | Full HTTP, OAuth, Mcp-Session-Id | TLS, audience-bound tokens, per-user session IDs |
| Legacy HTTP+SSE | Older remote servers | The same HTTP class, plus a long-lived SSE stream | Treat as debt; retest after a move to Streamable HTTP |
Are local stdio servers in scope?
Yes. Local stdio servers stay in scope. There is no in-protocol login. The process inherits the developer’s files, tokens, and roots. A pentest that only hits the remote HTTP listener misses the connector most engineers actually run. Ask which binary is pinned, which user it runs as, and which filesystem roots the client advertised.
On any HTTP listener bound to loopback, validate the Origin header and require authentication. DNS rebinding against a local MCP endpoint is a known bug class. CVE-2025-49596 was remote code execution in MCP Inspector via DNS rebinding, fixed in the 0.14.1 line and rated critical. CVE-2025-6514 was OS command injection in mcp-remote, triggered by a malicious authorization endpoint during OAuth, fixed in 0.1.16 and rated critical. Confirm the deployed versions before citing either as unpatched.
Session identifiers in Mcp-Session-Id must be server-generated, non-deterministic, and bound to the authenticated user. A client-supplied or reusable session ID is session fixation. A session ID that works for a second user is cross-user hijack. Send a second user’s token with the first user’s session ID and record which objects come back.
What is MCP tool poisoning, and how do rug pulls and shadowing differ?
MCP tool poisoning is an instruction hidden in a tool’s description or parameter text. The model reads it. The human approval dialog may never show it in full.
Invariant Labs published the original thread against Claude Desktop in April 2025, including a WhatsApp exfiltration demo and a preference-manipulation variant. The failure is a UI and trust bug as much as a model bug. Check whether this host truncates the description, collapses parameter docs, or shows only the argument list. If the dangerous sentence sits past the fold, the user approved a different tool than the model saw.
The test is a canary. Plant a distinctive, harmless instruction in the description: a unique marker the model must echo, or a benign side effect you control. Connect the server to the same host and model your users run. Record whether the approval UI showed the full string, whether the model complied, and whether a later tool call inherited the instruction.
That last case is what Invariant Labs called line jumping: smuggled text that tries to collapse later human approvals. Run the canary at least ten times. Report successes over trials, plus the client build, model name, and temperature. The control is the client rendering the full definition, plus server-side authorization that still rejects the call if the model complies.
Check “always allow” on that same host. If the grant is scoped to the server rather than the tool, a rug pull after a blanket approval widens the blast radius with no new dialog. Force a description change between sessions and see whether the host re-renders and re-prompts.
| Class | Who controls the input | What the model sees | What catches it |
|---|---|---|---|
| Tool poisoning | The MCP server author, at list time | Instructions inside a description the user may not fully see | Full description in the approval UI, plus pinned hashes |
| Rug pull | The same server, after approval | A new tool set or a rewritten description on a later list | A client that re-prompts when the definition hash changes |
| Cross-server shadowing | A second, less trusted server | Text that names another server’s tool and routes arguments here first | Per-tool approval and a refusal of cross-server instructions |
| Indirect prompt injection | Data the tool reads: issues, docs, pages, errors | Instructions inside tool results, resource bodies, or errors | Trifecta review: drop private data, untrusted content, or egress |
| Jailbreak | Anyone talking to the model | A policy bypass against the model’s own rules | A separate test. A model swap leaves MCP trust-chain bugs in place |
For a rug pull, approve a benign tools/list, then change the description or add a tool and reconnect. If the host keeps the old approval, file it against the client. For shadowing, stand up server B whose descriptions refer to a tool only server A should implement, including near-duplicate names. Watch whether arguments meant for A arrive at B.
File a jailbreak and an MCP injection as separate tickets. A jailbreak targets model policy and may vanish on the next model. An MCP injection targets the tool trust chain and survives the swap.
Indirect injection uses data the server is supposed to read. The May 2025 GitHub case is the template: untrusted content in an issue, ticket, doc, or page tells the model to use a second tool, reach private data, and write somewhere public. Reproduce that shape with records you own. Record the second call, any private fields in the outbound payload, and whether the approval UI showed the result that carried the instruction.
- Sampling. The server may call sampling/createMessage and ask the client’s model to complete a prompt. Look for unbounded or looping requests, denial of wallet (cost-exhaustion), and prompts that try to pull hidden client context back to the server.
- Elicitation. A server can ask the user for more information mid-call. Treat that as a phishing surface. A tool that elicits credentials the task does not need is a finding even if the model never sees those values.
- Roots. The client declares filesystem paths the server may touch. Ask for paths outside the declared root and for symlink escapes. A server that reads past the declared root is disclosing local files.
If the server has private data, untrusted content, and any egress, including a webhook tool, say so in the executive summary. That combination is the trifecta. Clean handler code does not remove it.
How do I test OAuth scopes, audience binding, and token passthrough?
Confirm the access token was minted for this MCP server, and that each tool enforces its scope on the server. A sentence in the prompt is not authorization.
Under the 2025-06-18 rules the server is a resource server. It must publish protected resource metadata (RFC 9728) so the client can find the authorization server (RFC 8414). Authorization requests must carry an RFC 8707 resource indicator so the token audience is this server. PKCE (RFC 7636) is mandatory on the authorization-code flow, which is the flow native and desktop hosts use under RFC 8252. Dynamic client registration (RFC 7591), if enabled, adds open registration, weak redirect matching, and clients that request scopes the tool set does not need.
- Replay a token minted for server A against server B. Acceptance means audience binding is missing. That is a spec violation.
- Call a write tool with a read-scoped token. Connecting, and even appearing in tools/list, is not authorization. The write must fail server-side.
- Compare scopes requested at registration and on the consent screen with the tools that exist. A connector that asks for repo, write, or admin to power a read-only search fails scope minimization. The consent screen has to show those scopes in language a user can refuse.
- Walk redirect_uri and state as you would on any OAuth client: exact matching, no open redirect, state bound to the browser session, PKCE verifier checked. CWE-601 and CWE-352 live on this hop.
- If tokens are JWTs, check RFC 9068 claims (iss, aud, exp, scope). If the resource server uses RFC 7662 introspection, confirm it introspects against the authorization server that issued the token.
The MCP authorization spec says servers must not accept tokens that were not issued for them, and must not forward the client’s token to an upstream API. Passthrough is a spec violation.
The failure mode is audience confusion. The proxy forwards a token minted for the MCP server, or a token minted for a broad upstream API, which widens the blast radius beyond the server. Downstream logs then show the user’s raw credential. Revocation and anomaly detection at the identity provider no longer match the hop that touched the data.
Detect it on the proxy. Terminate TLS on your intercepting proxy, perform a normal tool call, and compare the bearer sent upstream with the token the client presented to the MCP server. Identical tokens, or an upstream call whose audience claim is the MCP server rather than the upstream API, is passthrough. The server should obtain a separate upstream credential, audience-bound to that API, and should refuse any client token whose audience is not itself.
Also present a token from an unrelated resource server to the MCP endpoint. If the tool runs, audience checks are absent even when the happy-path OAuth dance looks finished.
Three deployment patterns show up on engagements. A server that is its own authorization server is simpler to reason about and easier to misconfigure. A server that trusts a separate authorization server is the pattern the spec wants, provided audience binding holds. A proxy that holds one static third-party client_id for every user is the confused-deputy pattern, and it needs its own pass.
What is the MCP confused deputy problem, and does OAuth stop it?
OAuth on the MCP port does not stop a confused deputy. The canonical case is a proxy that uses one static third-party client_id for every user.
The spec’s example is concrete. The MCP server sits between the user and a third-party API and registers a single client_id with that API’s authorization server. The authorization server cannot tell user A from user B, because both look like the same OAuth client. A cookie set during A’s consent flow can be replayed to finish consent as B. The deputy, the MCP server, then acts with B’s rights because the third party consented to the proxy, not to B’s session. That is CWE-441.
Test the pattern. Look for one shared client_id, consent state stored in a cookie that is not bound to the MCP user’s session, and a callback that completes authorization for whoever hits it next. Repeat the consent flow with two test users and see whether A’s consent artifact authorizes B. Detect it on the proxy by swapping upstream credentials and replaying consent cookies across users.
Also look for a mix-up between the MCP session and the upstream account. User A’s MCP token triggering upstream calls as a service account, or as user B, is the same bug. Tools are still an API. Resource URIs are object identifiers: call resources/read and tools/call with another user’s identifiers and look for IDOR (CWE-639). A tool argument the server fetches, a URL or a host, is an SSRF candidate (CWE-918), including cloud metadata if the process can reach it. The MCP security best-practices list names both.
On a clean proxy, logs should name the MCP user, the tool, the session id, and an upstream token audience that is the third-party API. A log line that carries the client’s raw bearer, or an upstream call under a shared service account with no user binding, is the finding. Alert on that pair, not on the string “MCP.”

How should we rate model-dependent findings and approve a vendor?
Report a model-dependent finding as successes over trials, with the client build, model name, and temperature written down. A single miss is not a clean result. A single hit is not a CVSS number you can defend alone.
How do I rate severity when the model has to comply?
Separate the trust-chain fault from the model’s compliance rate. Hidden or truncated description text, a missing re-approval after a definition change, missing audience checks, and a shared upstream client_id are defects whether or not this week’s model obeys.
Score impact from what the successful trials actually reached: private fields, another tenant, a credential, or an outbound write. If server-side authorization still rejects the call after the model complies, keep the UI bug and drop the data-exposure rating. If the server performs the call, rate the data, and cite the trial count so the reader can see how stable the path was.
What should we require before a third-party MCP connector is approved?
- Pinned spec revision and server version, retested when either changes. The baseline for this checklist is 2025-06-18.
- A published tools/list, including descriptions, schemas, and annotations, with a hash you can recheck after updates.
- RFC 8707 audience binding, PKCE, exact redirect matching, and a written refusal of token passthrough.
- A separate upstream credential per user. One static third-party client_id for every user is a reject.
- Per-tool approval, or a documented always-allow scope, plus a re-prompt when a definition hash changes.
- For stdio, the pinned binary, the OS user, and the filesystem roots.
- Logs that record user, tool, session, and upstream audience, and that do not store the client’s raw access token on the upstream call.

FAQ
Are stdio MCP servers in scope for a penetration test?
Yes. Stdio has no in-protocol login, and the process inherits the user's files, tokens, and roots. Test the pinned binary, the OS user, and the roots the client advertised, not only the remote HTTP listener.
Why does the MCP spec forbid token passthrough?
A token minted for the MCP server, or for a broad upstream API, is the wrong credential for the next hop. The spec says the server must not accept foreign tokens and must not forward the client's token upstream. Identical bearers on both sides of the proxy are the detection.
Does adding OAuth to an MCP server stop confused-deputy attacks?
No. OAuth on the MCP port still fails if the proxy uses one static third-party client_id for every user. Replay one user's consent cookie as the other user. If the second user is authorized, the deputy is live.
How do tool poisoning, rug pulls, and cross-server shadowing differ?
Poisoning hides instructions in the description at approval time. A rug pull changes that definition after approval. Shadowing is a second server whose text impersonates the first server's tool and pulls arguments across. Indirect injection rides in data the tool is supposed to read. Test each class in the real client.
How do I rate a finding that only works if the model complies?
Split the defect from the compliance rate. Missing audience checks, truncated approval text, and a shared client_id are defects on their own. Report successes over at least ten trials, name the client build, model, and temperature, and rate data exposure only if the server actually performed the call.
What should a defender see in MCP server logs?
Each call should log the MCP user, tool name, session id, and the audience of the upstream credential. The upstream audience should be the third-party API. A raw client bearer in that hop, or a shared service account with no user binding, is the signal to alert on.
If you want this plan run against a connector before it touches production agents, our MCP server and tool-chain security testing covers the host, the server, and the upstream identity hop in one engagement. Pair it with agentic AI threat modeling when the tool graph is still changing and you need the trust boundaries written down first.