Skip to content

test: verify auth-header forwarding on both HTTP transports - #105

Merged
memtomem merged 3 commits into
mainfrom
test/verify-crawl-headers
Sep 10, 2026
Merged

test: verify auth-header forwarding on both HTTP transports#105
memtomem merged 3 commits into
mainfrom
test/verify-crawl-headers

Conversation

@memtomem

@memtomem memtomem commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Closes #103. Stacked on #104 — base is test/verify-sse-transport, not
main. Merge #104 first, then retarget this to main before deleting that
branch, or GitHub closes this PR along with it.

What and why

ServerSpec.headers carries crawl credentials to upstream MCP servers. Both
HTTP transports forward it, through different SDK mechanisms:

transport how headers are handed to the SDK
streamable-http create_mcp_http_client(headers=...) passed as http_client= — no headers= kwarg exists
sse headers= straight through to sse_client

One spelling can stop carrying credentials without the other noticing, and
neither had ever been executed with a non-empty headers dict at any SDK
version. A regression there does not crash. It returns 401s, or worse, crawls a
smaller tool set and prunes real edges out of the governance graph. headers
already sits on the control-plan secret denylist next to api_key and
password.

Nothing was broken. Both transports forward headers correctly at mcp 2.1.1
and 2.2.0. This PR turns that into standing coverage.

Precisely what CI enforces, since the two layers differ:

streamable-http sse
pytest, lockfile 2.2.0 yes yes
floor script, pinned 2.1.1 yes no

SSE header forwarding at the declared floor is verified, but by a local run
recorded below, not by a job that will fail if it regresses. Adding that leg is
cheap if we decide the gap matters; it is called out rather than papered over.

The design decision worth reviewing

The first version asserted on the client's exception. Codex called that a
Major, correctly: the SDK funnels both a 401 and a 500 into the same generic
error for streamable-http, so a crashed or hung server would have satisfied the
assertion exactly as well as a real refusal. The negative half would have been
vacuous — the precise "looks verified but isn't" failure this repo keeps
getting bitten by.

So the evidence moved server-side. The fixture counts what it turned away and
serves the tally at an unauthenticated path. Each test pins the count at zero
after the authorized crawl, then requires it to rise after the anonymous one.
pytest.raises only establishes that the crawl failed; the tally establishes
why. _leaf_errors, _assert_auth_rejection and the MCPError import are
gone.

The middleware is raw ASGI rather than BaseHTTPMiddleware so ASGI messages
are forwarded untouched. BaseHTTPMiddleware would also work — it streams
rather than buffers — but it relays the long-lived SSE response through an extra
task and memory object stream for no benefit here. Non-HTTP scopes pass through
so the SDK's session manager still starts.

Mutation testing

Three deliberate breakages, all of which turn the suite red:

mutation result
if spec.headers:if False, and sse_client(headers=None) both header tests fail
fixture returns 500 without counting a rejection both header tests fail
floor script's anonymous probe aimed at a dead port no server rejection seen

The second is the case that passed under the exception-sniffing version.

Verification

uv run pytest -q                                585 passed
uv run ruff check .                             All checks passed
verify_mcp_floor.py @ mcp 2.2.0                 header forwarding ok
verify_mcp_floor.py @ mcp 2.1.1 (declared floor) header forwarding ok

The floor leg drives streamable-http only, since that is the 2.x-specific
spelling and the fragile one; pytest covers both transports at the lockfile
version. See the coverage table above for what that leaves unenforced. Also refreshes the minimum-mcp
comment in ci.yml, which still claimed the script covered only
streamable-http.

Codex re-reviewed after the fixes: SHIP, no blockers, no majors. Its
remaining minor — the floor script never pinned its authorized baseline at zero
the way pytest does — is fixed here too.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ue4wAZWpL7PFe7ARenR7fz

memtomem and others added 3 commits September 10, 2026 09:37
`ServerSpec` has accepted `transport: sse` since the beginning and
`crawler/transports.py` has always had a branch for it, but nothing in the
repository ever executed that branch. No test, no script, no CI job
constructed a spec with `transport="sse"`, and the fixture server could not
speak SSE at all, so the branch could not have been covered even by accident.
The MCP 2.x migration rewrote that module with SSE untested.

This is the same failure shape the `minimum-mcp` job was hardened against: a
public surface that looks supported because the type admits it.

The fixture server grows an `sse` mode, `test_crawl_sse` crawls it for real
through `crawl_server`, and `verify_mcp_floor.py` generalizes its
streamable-http leg into `http_transport(mode, transport, path)` so both HTTP
transports are driven at the declared SDK floor. Putting it there is
deliberate: the `minimum-mcp` job is a required PR check, so this cannot go
stale the way a schedule-only script can.

Verified green at both mcp 2.1.1 (the declared floor) and 2.2.0.

No runtime behaviour changes; `transports.py` is docstring-only.

Also from review:
- reap the fixture child after `kill()`, or teardown returns while it is still
  exiting and the next test races it for a port
- build the floor script's legs from factories, so a failing leg does not
  leave later coroutines unawaited and bury the real error in RuntimeWarnings

Headers forwarding stays unexercised on both HTTP transports; filed as #103.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue4wAZWpL7PFe7ARenR7fz
Closes #103.

`ServerSpec.headers` carries crawl credentials to upstream MCP servers, and
both HTTP transports forward it -- through different SDK mechanisms.
streamable-http cannot take a `headers=` kwarg, so we build a client with
`create_mcp_http_client(headers=...)` and pass it as `http_client=`; SSE hands
`headers=` straight to `sse_client`. One spelling can stop carrying
credentials without the other noticing.

Neither had ever run with a non-empty headers dict, at any SDK version. A
regression there does not crash: it returns 401s, or worse, crawls a smaller
tool set and prunes real edges out of the graph. `headers` already sits on the
control-plan secret denylist next to `api_key` and `password`.

Nothing was broken. Both transports forward headers correctly at mcp 2.1.1 and
2.2.0. This makes that a fact CI enforces.

The fixture grows `http-auth` / `sse-auth` modes that 401 any request without
the token, behind raw-ASGI middleware rather than BaseHTTPMiddleware, which
buffers responses and would break the SSE stream.

The evidence is the server's rejection tally, not the client's exception.
Asserting on the exception is vacuous: the SDK funnels a 401 and a 500 into the
same generic error for streamable-http, so a crashed or hung server would
satisfy `pytest.raises` exactly as well as a refusal. The fixture counts what
it turned away and serves the count at an unauthenticated path; each test pins
the count at zero for the authorized crawl, then requires it to rise for the
anonymous one.

Three mutations confirm the tests bite: dropping the header on either
transport, returning 500 without counting a rejection, and pointing the
anonymous probe at a dead port all turn the suite red. The second is the case
that passed under the exception-sniffing version.

Also refreshes the `minimum-mcp` comment, which still claimed the floor script
covered only streamable-http.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue4wAZWpL7PFe7ARenR7fz
The `RequireToken` docstring justified raw ASGI by saying Starlette's
BaseHTTPMiddleware buffers responses and would break the SSE stream. That is
not true. Its `body_stream()` yields each `http.response.body` message as it
arrives and wraps them in a `_StreamingResponse`, so it streams.

Raw ASGI is still the right choice here -- it forwards ASGI messages untouched,
where BaseHTTPMiddleware relays a connection meant to stay open through an
extra task and memory object stream -- but "it would not work" and "it is more
machinery than this needs" are different claims, and only the second one is
load-bearing.

Found by a whole-change review of main..HEAD. A wrong reason in a comment is
worse than no reason: it is what the next person will act on.

No behaviour change; docstring only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue4wAZWpL7PFe7ARenR7fz
@memtomem
memtomem changed the base branch from test/verify-sse-transport to main September 10, 2026 01:25
@memtomem
memtomem merged commit 9169788 into main Sep 10, 2026
14 checks passed
@memtomem
memtomem deleted the test/verify-crawl-headers branch September 10, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crawl headers are forwarded to upstream MCP servers but never tested on either HTTP transport

1 participant