test: verify auth-header forwarding on both HTTP transports - #105
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #103. Stacked on #104 — base is
test/verify-sse-transport, notmain. Merge #104 first, then retarget this tomainbefore deleting thatbranch, or GitHub closes this PR along with it.
What and why
ServerSpec.headerscarries crawl credentials to upstream MCP servers. BothHTTP transports forward it, through different SDK mechanisms:
create_mcp_http_client(headers=...)passed ashttp_client=— noheaders=kwarg existsheaders=straight through tosse_clientOne spelling can stop carrying credentials without the other noticing, and
neither had ever been executed with a non-empty
headersdict at any SDKversion. 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.
headersalready sits on the control-plan secret denylist next to
api_keyandpassword.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:
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.raisesonly establishes that the crawl failed; the tally establisheswhy.
_leaf_errors,_assert_auth_rejectionand theMCPErrorimport aregone.
The middleware is raw ASGI rather than
BaseHTTPMiddlewareso ASGI messagesare forwarded untouched.
BaseHTTPMiddlewarewould also work — it streamsrather 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:
if spec.headers:→if False, andsse_client(headers=None)no server rejection seenThe second is the case that passed under the exception-sniffing version.
Verification
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-mcpcomment in
ci.yml, which still claimed the script covered onlystreamable-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