I Wrote an MCP Server Auth Test Harness for the 2026-07-28 Spec. Here Is What Broke First
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 andalg: nonetokens.- 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_metadataparameter inWWW-Authenticateon 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
403witherror="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,scopeandexpa test asks for, and serves a JWKS. - A small MCP server with two tools:
read_notes(needsnotes:read) anddelete_note(needsnotes:write). Both call a fake downstream API that records everyAuthorizationheader it receives. - Two modes of that server.
naiveis my attempt at the typical “make it work” version: it verifies the signature and nothing else, letstools/listthrough without a token, accepts?access_token=as a fallback, returns a bare 401, and forwards the caller’s token downstream.hardenedfixes 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

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.

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

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
algchecks 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-Authenticatemechanism 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
Authorizationheader, so the hardened server would return 401 even without its explicit query-string check. - Not covered:
Originheader validation (a Streamable HTTP MUST), and client-side rules like sending the RFC 8707resourceparameter. 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.
About the author
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.
Related articles
-
10 min read
I Traced 29 CVEs in One MCP Server to 4 Root Causes
29 mcp-atlassian CVEs traced to 4 root causes: DNS rebinding, credential fallback, tool allowlist and file_path. The safe version floor is 0.23.1, not 0.22.0.
- MCP Security
- Security Engineering
-
10 min read
MCP Servers Had a Rough 48 Hours: 4 Unauthenticated CVEs
Four MCP servers published unauthenticated CVEs in about 35 hours, plus LiteLLM's KEV-listed bypass. Fixed versions and a checklist to audit your MCP servers.
- MCP Security
- Security Engineering
-
7 min read
Read-Only Postgres MCP Servers Fail With 1 SQL Keyword
A crafted COPY ... TO PROGRAM got past AWS's read-only postgres-mcp-server (CVSS 9.6). Four checks for your own SQL validator, and why the role is the boundary.
- MCP Security
- Security Engineering
All writing | RSS | Markdown