Skip to content

I Wrote an MCP Server Auth Test Harness for the 2026-07-28 Spec. Here Is What Broke First

Published Last updated 8 min read

Key takeaways

  • The MCP 2026-07-28 authorization rules match standard OAuth 2.1 resource-server behaviour, so they can be tested automatically.
  • A 10-test pytest harness of about 100 lines scored a deliberately typical server at 2 of 10 and a hardened version at 10 of 10.
  • The naive server's two passes came from library defaults: PyJWT checks expiry, and pinning algorithms=["RS256"] blocks forged and alg: none tokens.
  • The worst result combined two failures: a token minted for another service was accepted, then forwarded downstream, the confused-deputy pattern the spec forbids.
  • Validating audience and issuer, returning discovery metadata on every 401, checking scope per tool and using the server's own downstream credential fix six of the eight failures.

The MCP 2026-07-28 revision makes authorization for HTTP servers look a lot like ordinary OAuth: the server is a resource server, it publishes metadata, it validates audience, and it never forwards the client’s token. That’s good news, because “ordinary OAuth” is something we already know how to test.

So I wrote the tests. Ten of them, each tied to a specific MUST or SHOULD in the spec, run against two versions of the same small MCP server. The quickstart-style version passed 2. The hardened version passed 10. The interesting part is which two passed, and why.

TL;DR

  • MCP servers that use HTTP now have clear authorization rules in the 2026-07-28 spec. They match standard OAuth 2.1 resource-server behaviour, so they can be tested automatically.
  • I wrote a 10-test harness (pytest, ~100 lines) and ran it against a deliberately typical “it works” server and a hardened one. Naive: 2/10. Hardened: 10/10.
  • The naive server’s two passes came for free from library defaults: PyJWT checks expiry by default, and pinning algorithms=["RS256"] blocks forged and alg: none tokens.
  • The worst failure was two tests combined: a token minted for a different service was accepted (no audience check), and the server forwarded it downstream. That’s the confused-deputy pattern the spec explicitly forbids.
  • Four fixes close six of the eight failures: validate audience and issuer, return discovery metadata on every 401, check scope per tool, and use the server’s own credential downstream. Two policy changes (no anonymous tools/list, no query-string tokens) close the rest.

What the spec actually requires

I tested against the authorization pages of the 2026-07-28 revision. These are the lines that turned into tests, quoted as written:

  • “MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2.”
  • “Invalid or expired tokens MUST receive a HTTP 401 response.”
  • “MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728)”, and the server MUST implement a discovery mechanism, either the resource_metadata parameter in WWW-Authenticate on 401 or the well-known URI (discovery page).
  • “The MCP server MUST NOT pass through the token it received from the MCP client.” (security considerations)
  • “Access tokens MUST NOT be included in the URI query string.”
  • For insufficient scope, the server SHOULD answer 403 with error="insufficient_scope".

One more change matters for anyone writing tests: the 2026-07-28 Streamable HTTP transport removed protocol-level sessions and the initialize handshake. Every request carries its own MCP-Protocol-Version and Mcp-Method headers (transport page). So the harness doesn’t do a handshake. It sends one self-contained request per test, which makes the tests short and independent.

The setup

Everything runs on 127.0.0.1:

  • A stub authorization server that mints RS256 JWTs with whatever aud, iss, scope and exp a test asks for, and serves a JWKS.
  • A small MCP server with two tools: read_notes (needs notes:read) and delete_note (needs notes:write). Both call a fake downstream API that records every Authorization header it receives.
  • Two modes of that server. naive is my attempt at the typical “make it work” version: it verifies the signature and nothing else, lets tools/list through without a token, accepts ?access_token= as a fallback, returns a bare 401, and forwards the caller’s token downstream. hardened fixes each of those.

To be clear about the limits: I wrote the naive server, so the scoreboard measures the gap between two implementations of mine, not any real product. It shows which mistakes a test suite catches and how cheap the fixes are.

The scoreboard

Scoreboard of 10 tests: the naive server fails 8 and passes only expired-token and forged-signature checks; the hardened server passes all 10.

Both servers also pass a control test: a valid token with the right scope gets a 200. Without that, a server that rejects everything would score 10/10.

What broke first: test 01. With no token, the naive server returns a bare 401 with no WWW-Authenticate header. Test 02 then shows the fallback is missing too: the Protected Resource Metadata endpoint is a 404. The spec requires one of those two discovery mechanisms; the naive server has neither, so a spec-following client has no way to find the authorization server and can’t even start the flow.

The two passes were free. Expired tokens are rejected because PyJWT validates exp by default. Forged signatures and alg: none are rejected because the decode call pins algorithms=["RS256"]. Neither was a decision anyone made about MCP. Switch to a library with different defaults and both could flip without anyone noticing, which is exactly why they belong in a test suite.

The worst result needed two tests together.

Diagram: a client holding a token minted for another service calls the naive MCP server, which skips the audience check, returns 200 and forwards the same token to the downstream API.

Test 04 sent a token whose audience was a different service. The naive server returned 200. Test 09 showed that the naive server forwards the caller’s token to the downstream API as-is. Put together, a token issued for something else is accepted and then replayed with the MCP server’s position in the network behind it. That’s the confused-deputy chain the “MUST NOT pass through” rule exists to prevent.

Here’s the passthrough test. It’s short because the fake downstream does the work:

def test_09_no_token_passthrough(env):
    """R9: 'The MCP server MUST NOT pass through the token it received from the MCP client.'"""
    t = tok(env)
    env.ds.seen.clear()
    r = rpc(env, "tools/call", {"name": "read_notes", "arguments": {}}, token=t)
    assert r.status_code == 200
    assert env.ds.seen, "downstream was not called"
    assert all(a != f"Bearer {t}" for _, _, a in env.ds.seen), env.ds.seen

The fixes

Four fixes: validate audience, issuer and algorithm; return resource_metadata on every 401; check scope per tool; call downstream with the server's own credential.

1. Audience, issuer and algorithm in one call. This single change fixed tests 04 and 05:

claims = jwt.decode(
    token, signing_key(token),
    algorithms=["RS256"],                      # never trust the header's alg; rejects "none"
    audience=cfg.resource,                     # MUST validate audience (RFC 8707)
    issuer=cfg.as_url,                         # token must come from our AS
    options={"require": ["exp", "iss", "aud"]},
)

The require option makes the rule explicit: a token that simply omits aud, iss or exp is rejected by policy, not by whatever your JWT library happens to do by default.

2. Discovery on every 401. Serve the metadata document and point to it from the challenge (abridged):

def www_authenticate(error=None, scope=None, desc=None):
    parts = [f'resource_metadata="{cfg.prm_url}"']
    if scope:
        parts.append(f'scope="{scope}"')
    if error:
        parts.append(f'error="{error}"')
    return "Bearer " + ", ".join(parts)

The metadata at /.well-known/oauth-protected-resource/mcp lists the resource, at least one entry in authorization_servers (the spec requires it), and the supported scopes.

3. Scope per tool, not per server. A token that can read notes shouldn’t be able to delete them (abridged):

if method == "tools/call":
    need = TOOL_SCOPE.get(tool)
    have = set(str(claims.get("scope", "")).split())
    if need and need not in have:
        raise AuthError(403, "insufficient_scope", scope=need)

4. No passthrough. The server calls downstream with its own credential. In the harness that’s a one-line difference:

ds_auth = f"Bearer {token}" if cfg.mode == "naive" else f"Bearer {cfg.downstream_token}"

In production, “its own credential” means a separate token the MCP server gets from the upstream authorization server, which is what the security considerations page describes.

The hardened server also requires a token for tools/list and rejects ?access_token= outright. Those two are policy choices more than hard spec requirements; see the caveats below.

Run it against your own server

The harness starts the stub authorization server, the fake downstream and the MCP server itself, then runs the tests. To point it at your own server, change the server launch and URLs in tests/conftest.py, and configure your server to trust the stub’s JWKS in a test environment. Keep the stub: several tests need tokens a real authorization server won’t mint on purpose (wrong issuer, forged key, alg: none). It needs Python 3.14 and uv; from the harness directory, in bash:

MCP_MODE=naive    uv run pytest tests -v > results-naive.txt
MCP_MODE=hardened uv run pytest tests -v > results-hardened.txt
uv run python make_results.py

If you only have ten minutes, run tests 01, 04 and 09 by hand with curl: no token, a token for another audience, and a valid token while you watch what your downstream receives. Those three cover the failures with the biggest blast radius.

Caveats

  • The baseline is mine. It’s a plausible quickstart, not a measured sample of real servers. Don’t read 2/10 as a statistic about the ecosystem.
  • Some tests are stricter than the spec. Test 08 (anonymous tools/list) encodes “servers SHOULD implement proper authentication for all connections”, not a MUST; some servers legitimately expose discovery publicly. Test 10 (query-string token) applies a client-side MUST NOT on the server side.
  • Issuer and alg checks are my reading of standard JWT validation. The MCP spec says to validate tokens “as described in OAuth 2.1 Section 5.2” without listing these checks itself.
  • Test 01 is stricter than the spec. The spec requires either the WWW-Authenticate mechanism or the well-known URI; I test both because clients rely on the header in practice.
  • Test 10 is weak for the hardened server. The request carries no Authorization header, so the hardened server would return 401 even without its explicit query-string check.
  • Not covered: Origin header validation (a Streamable HTTP MUST), and client-side rules like sending the RFC 8707 resource parameter. Those need a different harness.
  • The authorization server is a stub. No real OAuth flows, consent or token exchange.

The takeaway

Most of what the 2026-07-28 spec asks of an MCP server is a handful of lines in the token-validation path. The cost of getting it wrong isn’t one bug; it’s the combination, where a token for something else gets accepted and then forwarded. A ten-test suite that runs in under five seconds is a cheap way to make sure that combination can’t ship.

Which of these ten would your MCP server fail today? My guess is test 01, because it’s the one nobody notices until a spec-following client tries to connect.

Follow-up: I pointed 3 static scanners at an MCP server that fails 8 auth tests, and they found 0.

Frequently asked questions

What does the MCP 2026-07-28 spec require for token audience?
It says MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2.
Can an MCP server forward the client's token to a downstream API?
No. The spec says the MCP server MUST NOT pass through the token it received from the MCP client.
How does a client discover the authorization server?
The server must implement OAuth 2.0 Protected Resource Metadata (RFC 9728), either through the resource_metadata parameter in WWW-Authenticate on a 401 or through the well-known URI.
Does the harness need an MCP session handshake?
No. The 2026-07-28 Streamable HTTP transport removed protocol-level sessions and the initialize handshake, so each test sends one self-contained request.
Share Share on X Share on LinkedIn

About the author

Portrait of Kiell Tampubolon

Security Engineer & Technical Writer

Security engineer and technical writer based in Batam, Indonesia. I review and harden MCP servers and AI agents, build security automation, and write up what holds and what fails. I also train on DNS security as a PANDI .id Academy trainer.

All writing | RSS | Markdown