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

> Ten tests mapped to the MCP 2026-07-28 authorization spec. A quickstart-style server passed 2. Four fixes and two policy changes got it to 10.

- URL: https://www.kielltampubolon.id/writing/i-wrote-an-mcp-server-auth-test-harness-for-the-2026-07-28-spec-here-is-what-broke-first/
- Author: Kiell Tampubolon
- Published: 2026-10-25
- Last updated: 2026-10-25
- Language: en
- Tags: MCP Security, Security Engineering

## 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery)).
- "The MCP server **MUST NOT** pass through the token it received from the MCP client." ([security considerations](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)). 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.](https://www.kielltampubolon.id/writing/i-wrote-an-mcp-server-auth-test-harness-for-the-2026-07-28-spec-here-is-what-broke-first/img-1.png)

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.](https://www.kielltampubolon.id/writing/i-wrote-an-mcp-server-auth-test-harness-for-the-2026-07-28-spec-here-is-what-broke-first/img-2.png)

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:

```python
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.](https://www.kielltampubolon.id/writing/i-wrote-an-mcp-server-auth-test-harness-for-the-2026-07-28-spec-here-is-what-broke-first/img-3.png)

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

```python
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):

```python
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):

```python
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:

```python
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:

```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](https://www.kielltampubolon.id/writing/i-pointed-3-static-scanners-at-an-mcp-server-that-fails-8-auth-tests-they-found-0/).

## FAQ

### 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.
