Adversarial Testing for MCP Servers: The Pre-Handover Test Plan

Every MCP server post on this site ends with some version of "then test it". This one is the test plan. It is the sequence we run against a server before handover, and the same sequence we run in the first days of an audit. None of it needs a specialist tool. It needs a second token, a scratch tenant, and a written record of what the server returned.

The point is not to find clever bugs. The point is to produce evidence. A reviewer who receives a list of attempted attacks and the server's response to each one asks fewer follow-up questions than a reviewer who receives a paragraph about your security posture.

Set up two of everything

Before any test, provision the fixtures. Most failed attempts to test an MCP server fail here, because the engineer has one token and one tenant and cannot express the interesting cases.

  • Two tenants, each with data the other must never see. Give the records recognisable values so a leak is obvious in a diff.
  • Two users in one tenant with different roles: one with broad product permissions, one deliberately restricted.
  • A token for a different resource in the same identity provider, for the audience test. Any other API registered with your IdP will do.
  • An expired token and a token whose signature you have altered by one character.
  • A client that is registered and a client that is not.

Record how each fixture was minted. That note is part of the evidence; a test nobody can reproduce is a claim.

Authorization: the four rejections

Run these against the server's MCP endpoint directly, not through a client. You are testing the server, and the client is not part of your security boundary.

# 1. No token: expect 401 with a WWW-Authenticate challenge
curl -si -X POST https://mcp.example.com/mcp | grep -i www-authenticate

# 2. Token for another resource: expect 401 invalid_token
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $OTHER_RESOURCE_TOKEN" | head -1

# 3. Expired token: expect 401, never 500
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $EXPIRED_TOKEN" | head -1

# 4. Tampered signature: expect 401
curl -si -X POST https://mcp.example.com/mcp \
  -H "Authorization: Bearer $TAMPERED_TOKEN" | head -1

Case 2 is the one that finds real defects, because a server that decodes a token without checking aud against its own resource identifier will happily accept it. RFC 8707 resource indicators exist so that this test is possible; if the token you minted has no audience at all, the gap is in the authorization server, and that is worth writing down too.

Then repeat case 1 against every route the deployment exposes: the MCP endpoint, any health or metrics path, the metadata document, and anything a proxy added. Unauthenticated metrics endpoints that list tenant identifiers are a recurring finding.

Cross-tenant: every tool, not one tool

This is the test that decides the review, and it has to be exhaustive rather than representative. For each tool the server exposes, call it with tenant A's token and an identifier belonging to tenant B.

Work through the identifier surface deliberately: primary keys, slugs, external references, file paths, search filters, pagination cursors issued to the other tenant, and any argument that looks like a query the data layer will pass through. Expect a 403-class error or an empty result, and expect the same answer whether or not the record exists. A "not found" for a foreign record and a "forbidden" for a foreign record you may not read are distinguishable, and the difference is an enumeration oracle.

Two variants find bugs that the simple case misses:

  • Argument-supplied identity. If any tool accepts user_id, tenant_id, account, or on_behalf_of, set it to the other principal. The correct behaviour is that the argument is ignored or the call is rejected, because identity comes from the token. A tool argument is a suggestion, never an authority.
  • Second-hop calls. Tools that fan out to a search index, a cache, a warehouse, or a downstream API often enforce access only in the first hop. Query the downstream store directly with the identity the server used and see whether it was narrowed.

Prompt injection: attack the tool outputs

Your server cannot stop a model from following instructions it reads. It can limit what the instructions reach. Test the containment, not the model.

Seed injected text into every field that a third party can write and a tool can return: ticket bodies, customer names, document contents, email subjects, file metadata, webhook payloads. Use instructions that would be visible if followed, for example a request to call a write tool with a fixed recognisable value, or to include a marker string in the reply.

Then drive the server with a real client and check three things. Did a write tool fire without a human confirmation. Did data cross a tenant boundary. Did the audit log record the sequence well enough that you could reconstruct it afterwards. If a write happened because the model asked for it and the server allowed it, the finding is not "the model was fooled". The finding is that a write tool was reachable without a confirmation the model cannot forge.

Also inject into your own tool metadata deliberately once, in a scratch environment, to see what a compromised upstream description could do. If tool descriptions are assembled from data your customers control, that is a supply-chain path and it belongs in the threat model.

Sessions, transport, and floods

For a remote server on Streamable HTTP, replay tenant A's session identifier with tenant B's token and confirm the session is rejected rather than adopted. Resume a stream with another session's Last-Event-ID. Send a cross-origin request from an origin that is not on the allow-list. Post a body an order of magnitude larger than any legitimate call. Then call one tool a few hundred times in a loop with one user's token and see whether a per-user, per-tool limit exists.

None of these are exotic. They are the cases that stop existing when a server moves from a laptop over stdio to a URL, and nobody revisits the assumptions.

Write the record, including the failures

The output of this work is a table, one row per attempt: what was sent, which fixture was used, what came back, and the verdict. Attach a redacted sample of the audit events the attempts produced, because a reviewer wants to know that a denied call is visible, not only that it was denied.

Keep the rows that failed and the remediation beside them. A test record with three fixed findings is more credible than a record with none, and a reviewer who sees only passes usually assumes the tests were written after the code.

Two honest limits. This plan tests authorization, isolation, containment, and logging. It is not a penetration test of your product, and it does not cover the identity provider or the client application. And if your product has no per-user access control underneath the API, no amount of testing at the MCP layer will produce a passing result; the work is one layer down.

Adversarial testing is a standard part of an MCP server build before handover, and the first week of an MCP security audit is largely this sequence run against what you already have. If you have run these tests yourself and hold the record, you are ahead of most servers we see, and you may not need us for this part.