Skip to content

Authorization inspector for HTTP sessions, from challenge to token facts #203

Description

@kerlenton

Context

A developer pointed at a remote MCP server sees a 401 then a run of 403s and has no way to tell from mcpsnoop whether the token has the wrong audience, is missing a scope, or expired. mcpsnoop sits in the one place where the challenge and the credential both pass, and today it treats the challenge as display text and never looks at the credential at all.

The response side is captured. internal/proxy/http.go:251 reads WWW-Authenticate off every response into route.challenge (internal/proxy/http.go:89), and internal/proxy/frame.go:100-106 carries it as AuthChallenge, whose own comment calls it "the one response header worth keeping verbatim". Nothing reads it back except display. internal/tui/view.go:706-707 concatenates it onto a list row, internal/tui/view.go:1121-1122 prints it in the inspector, internal/exporter/exporter.go:810 copies it to the JSON export, internal/store/views.go:80-81 passes it through. A grep for insufficient_scope, resource_metadata, error_description or 8707 across the tree hits five test fixtures and no production code.

The request side is not captured at all. The route struct at internal/proxy/http.go:76-90 holds Mcp-Method, Mcp-Name, MCP-Protocol-Version, the Mcp-Param-* family, the connection address, the status and the challenge. internal/proxy/http.go:308-314 builds it from the request headers and never touches Authorization. The request URL is not recorded either, so a token in the query string is invisible too.

Reproduction

A protected server that walks a client up a step-up ladder. First a 401 with a resource_metadata and no scope, then a 403 insufficient_scope demanding files:read, then a second 403 demanding files:write, then success. The client sent Authorization: Bearer <jwt> on attempts 2, 3 and 4, and that JWT carries "aud":"https://wrong.example/mcp" while the proxy was dialling http://127.0.0.1:3999/mcp.

$ mcpsnoop http --target http://127.0.0.1:3999/mcp --listen 127.0.0.1:7099 --label protected
mcpsnoop: proxying 127.0.0.1:7099 → http://127.0.0.1:3999/mcp (session protected-99380-b6a2913d7ecc)

The captured log, verbatim.

{"session_id":"protected-99380-b6a2913d7ecc","seq":1,"direction":"c2s","transport":"http","raw":{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"files.read","arguments":{"path":"/etc/hosts"}}},"mcp_method":"tools/call","mcp_name":"files.read","mcp_protocol_version":"2026-07-28","conn_id":"127.0.0.1:61836"}
{"session_id":"protected-99380-b6a2913d7ecc","seq":2,"direction":"s2c","transport":"http","conn_id":"127.0.0.1:61836","status":401,"auth_challenge":"Bearer resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\""}
{"session_id":"protected-99380-b6a2913d7ecc","seq":3,"direction":"c2s","transport":"http","raw":{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"files.read","arguments":{"path":"/etc/hosts"}}},"mcp_method":"tools/call","mcp_name":"files.read","mcp_protocol_version":"2026-07-28","conn_id":"127.0.0.1:61839"}
{"session_id":"protected-99380-b6a2913d7ecc","seq":4,"direction":"s2c","transport":"http","conn_id":"127.0.0.1:61839","status":403,"auth_challenge":"Bearer error=\"insufficient_scope\", scope=\"files:read\", resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\", error_description=\"Read permission required\""}
{"session_id":"protected-99380-b6a2913d7ecc","seq":5,"direction":"c2s","transport":"http","raw":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"files.read","arguments":{"path":"/etc/hosts"}}},"mcp_method":"tools/call","mcp_name":"files.read","mcp_protocol_version":"2026-07-28","conn_id":"127.0.0.1:61840"}
{"session_id":"protected-99380-b6a2913d7ecc","seq":6,"direction":"s2c","transport":"http","conn_id":"127.0.0.1:61840","status":403,"auth_challenge":"Bearer error=\"insufficient_scope\", scope=\"files:write\", resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\""}
{"session_id":"protected-99380-b6a2913d7ecc","seq":7,"direction":"c2s","transport":"http","raw":{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"files.read","arguments":{"path":"/etc/hosts"}}},"mcp_method":"tools/call","mcp_name":"files.read","mcp_protocol_version":"2026-07-28","conn_id":"127.0.0.1:61841"}
{"session_id":"protected-99380-b6a2913d7ecc","seq":8,"direction":"s2c","transport":"http","raw":{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"ok"}]}},"conn_id":"127.0.0.1:61841","status":200}

Everything the server said is there as one opaque string. The Authorization header appears nowhere, so the wrong audience is not recoverable from the capture at all. What check makes of it.

$ mcpsnoop check protected-99380-b6a2913d7ecc
session protected-99380-b6a2913d7ecc: errors=3 invalid=0 warnings=5 mismatches=0 pending=3 deprecated=0 missing_frames=0
check failed: error,warn

Not one signal names authorization. The warnings are _meta completeness complaints on the client requests, and the three errors are the 401 and the two 403s counted by httpFailed at internal/store/store.go:109, which knows only that the status was at least 400. A second insufficient_scope challenge demanding a scope the first never named, which is the exact anti-pattern the spec calls out, reads here as "errors=3".

The text export loses even the display. writeText at internal/exporter/exporter.go:837-866 builds the title from seq, timestamp, direction, kind, method, id, warning, truncated, deprecated and the cache fields, and never reads Status or AuthChallenge.

$ mcpsnoop export protected-99380-b6a2913d7ecc -T text | grep transport
#2 2026-08-01T19:32:23.003591+03:00 s2c transport
#4 2026-08-01T19:32:38.400669+03:00 s2c transport
#6 2026-08-01T19:32:38.409562+03:00 s2c transport

The JSON export does keep both, which is what makes the text gap a plain omission rather than a design choice.

$ mcpsnoop export protected-99380-b6a2913d7ecc -T json | jq -c '.events[] | select(.kind=="transport")'
{"seq":2,"direction":"s2c","kind":"transport","http_status":401,"auth_challenge":"Bearer resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\""}
{"seq":4,"direction":"s2c","kind":"transport","http_status":403,"auth_challenge":"Bearer error=\"insufficient_scope\", scope=\"files:read\", resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\", error_description=\"Read permission required\""}
{"seq":6,"direction":"s2c","kind":"transport","http_status":403,"auth_challenge":"Bearer error=\"insufficient_scope\", scope=\"files:write\", resource_metadata=\"https://auth.example/.well-known/oauth-protected-resource\""}

What the spec makes decidable

From Authorization, Scope Selection Strategy.

MCP servers SHOULD include a scope parameter in the WWW-Authenticate header as defined in RFC 6750 Section 3 to indicate the scopes required for accessing the resource.

Same page, Runtime Insufficient Scope Errors, on what a 403 carries.

  • error="insufficient_scope" - indicating the specific type of authorization failure
  • scope="required_scope1 required_scope2" - specifying the minimum scopes needed for the operation
  • resource_metadata - the URI of the Protected Resource Metadata document (for consistency with 401 responses)

Same section, on incremental challenges.

Whatever scope-inclusion strategy a server adopts, servers SHOULD include all scopes required for the current operation in a single challenge. Challenging incrementally (returning one missing scope, then another on the subsequent retry) forces multiple authorization round-trips for a single operation and degrades user experience.

Same page, Step-Up Authorization Flow.

Clients SHOULD implement retry limits and SHOULD track scope upgrade attempts to avoid repeated failures for the same resource and operation combination.

Same page, Access Token Usage.

Note that authorization MUST be included in every HTTP request from client to server.

Access tokens MUST NOT be included in the URI query string

Same page, Token Handling.

Invalid or expired tokens MUST receive a HTTP 401 response.

And from Authorization Security Considerations, Access Token Privilege Restriction.

MCP servers MUST only accept tokens specifically intended for themselves and MUST reject tokens that do not include them in the audience claim or otherwise verify that they are the intended recipient of the token.

Every one of those is a statement about bytes that already cross the proxy, or that cross it and are thrown away.

Decision needed first

Whether mcpsnoop may derive anything from the Authorization request header. Today it observes four request header families and every one of them is routing metadata. It has never touched credential material, and the redaction surface exists precisely to keep secrets out of the capture. Phase 2 below changes that posture, storing a truncated hash of the credential plus its unverified JWT claims and never the credential itself. Phase 1 needs nothing new on the wire and can land alone, so settle the phase 2 question before anyone writes it rather than after.

Proposed change

Phase 1, parse the challenge and build the step-up ladder

Parse WWW-Authenticate per the RFC 9110 credentials grammar into a scheme plus auth-params, keeping resource_metadata, scope, error and error_description. Store the parsed form beside the verbatim string rather than in place of it, because the raw header is the evidence and a parse can be wrong.

Correlate consecutive attempts at one operation into a ladder. The store already knows the operation, it keeps opName for exactly this kind of link at internal/store/store.go:148-150, and callKey at internal/store/store.go:113-123 already separates one client's id space from another's. The ladder records the challenges in order, the scope set each demanded, how many attempts the client burned, and whether the operation ever succeeded.

Four warnings, each decidable from the header bytes with no guess about intent.

  1. A 401 or 403 carrying a Bearer challenge whose params omit scope.
  2. A 403 carrying a Bearer challenge whose params omit error="insufficient_scope".
  3. A Bearer challenge that does not parse as RFC 9110 auth-params.
  4. Two insufficient_scope challenges for the same operation where the second demands a scope the first did not name.

Number 2 is deliberately gated on a challenge being present. A 403 with no WWW-Authenticate at all may be an ordinary policy denial with nothing to do with scope, and calling that an authorization failure would be exactly the intent-guessing the house rules forbid. These four are ordinary protocol warnings, so they ride the existing warn signal and fail a default check run.

Two facts that get reported and never warned. A challenge without resource_metadata is legal, because Authorization Server Discovery lets a server pick either mechanism, and mcpsnoop proxies only the MCP endpoint so it cannot observe the well-known fetch that would settle it. And the attempt count, because "no more than a few times" names no number, so a threshold belongs on a flag. Add --max-auth-attempts N to check as an assertion in the shape of the existing --max-duration, off unless asked for.

Surface the ladder as an Authorization pane in the TUI beside the capability inspector, and as a block in export -T json.

Fix writeText while in there, so a transport frame prints its status and challenge instead of a bare kind. It is the same feature and a one-line omission.

Phase 2, token facts behind an explicit flag

Behind --observe-auth, off by default, record derived facts from the Authorization request header on a new envelope field. Never the credential. A truncated SHA-256 of the credential so rotation and reuse across a session are visible, the auth scheme, and when the credential is a JWT the unverified aud, iss, scope, exp and sub read from the payload segment with no signature verification. Also record whether the request URL carried a token in its query string, which needs no JWT parsing at all.

That parse belongs in internal/proxy/http.go and nowhere near the shim, so the shim stays as dumb as it is now and stdio sessions are untouched. The spec agrees on the scoping, it says stdio implementations "SHOULD NOT follow this specification, and instead retrieve credentials from the environment".

One default warning comes out of this half, and it rests on nothing unverified. A token in the query string is a flat MUST NOT with nothing to interpret.

Two findings that are reported and gated behind an explicit check --fail-on auth rather than the default set, because both rest on a claim mcpsnoop cannot verify. A 200 answered to a token whose aud does not name the target the proxy is dialling, and a 200 answered to a token whose exp was already past at request time. An aud may legitimately be an opaque resource identifier that is not the URL mcpsnoop dials, so these must not fail a default run.

The new field goes through Redactor.RedactEnvelope. redactMCPParamHeaders at internal/proxy/redact.go:202-231 is the precedent for scrubbing a header-derived field and for flagging the envelope Redacted when it does.

Out of scope

  • Anything off the MCP endpoint. mcpsnoop proxies one URL, so the protected resource metadata document, the authorization server metadata, the token endpoint and the redirect callback never pass through it. No check here may depend on fetching them, and mcpsnoop still makes no network calls of its own.
  • Signature verification of any token. mcpsnoop holds no keys and will not fetch a JWKS. Every claim it reports is labelled unverified.
  • PKCE, iss validation per RFC 9207, redirect URI checks, client registration. All of it happens between the client and the authorization server, outside the data path.
  • Judging whether a demanded scope is the right scope for an operation, or whether a client's scope union was correct. Both are intent.
  • stdio sessions, by the spec's own transport scoping.
  • Storing, forwarding, logging or displaying the credential itself in any form, under any flag.

Acceptance criteria

  • A Bearer challenge is parsed into scheme, resource_metadata, scope, error and error_description, and the verbatim header is still kept alongside it.
  • A challenge that does not parse against the RFC 9110 auth-param grammar raises a warning and leaves the raw string intact rather than dropping the frame.
  • A 401 or a 403 whose Bearer challenge omits scope raises a warning.
  • A 403 whose Bearer challenge omits error="insufficient_scope" raises a warning, and a 403 with no WWW-Authenticate at all raises none.
  • Two insufficient_scope challenges for the same operation where the second names a scope the first did not raise a warning, and two identical challenges do not.
  • A missing resource_metadata is reported and never warned.
  • The ladder for one operation reports the ordered challenges, the scope set each demanded, the attempt count and whether the operation ever succeeded, and the session in the reproduction above yields one ladder for files.read with three challenges, four attempts and a success.
  • check --max-auth-attempts 2 fails on that session and check without the flag does not fail on the ladder length alone.
  • export -T json carries the parsed challenge and the ladder, and export -T text prints the status and the challenge on a transport frame.
  • The TUI shows an Authorization pane for an HTTP session and shows nothing for a stdio session.
  • Without --observe-auth no capture contains any field derived from an Authorization request header, proven by a test that sends one and asserts the envelope is unchanged.
  • With --observe-auth the capture holds the scheme, a truncated hash and the unverified JWT claims, and a test asserts the credential's own bytes appear nowhere in the envelope, the JSONL or any export.
  • A token in the request URL query string raises a warning on the default signal set.
  • A wrong aud or a past exp on a request the server answered 200 is reported, fails only under check --fail-on auth, and passes a default check run.
  • The stdio shim is unchanged, with no new parsing and no new envelope field populated on TransportStdio.
  • The derived field passes through Redactor.RedactEnvelope and sets Redacted when scrubbed.

Files

  • internal/proxy/http.go, the route struct at 76-90 gains the request-side fields, the handler at 306-322 reads Authorization and the URL query under the flag, and ModifyResponse at 248-251 keeps emitting the verbatim challenge.
  • internal/proxy/frame.go, Envelope at 100-106 gains the parsed challenge and the derived token facts beside the existing AuthChallenge.
  • internal/proxy/redact.go, extend RedactEnvelope at 135-190 to cover the new field, following redactMCPParamHeaders at 202.
  • internal/store/, a new auth.go for the challenge parser and the ladder, wired from store.go:340 where the envelope becomes an event, next to the existing conformance work in conformance.go.
  • internal/store/views.go, expose the parsed challenge and the ladder past 74-84.
  • internal/tui/view.go, the Authorization pane, plus the existing raw lines at 706-707 and 1121-1122.
  • internal/exporter/exporter.go, the export struct at 145-160, the copy at 805-815, and the writeText title at 837-866.
  • cmd/mcpsnoop/http.go for --observe-auth, and cmd/mcpsnoop/check.go for --max-auth-attempts and the auth signal at 141 and 225.
  • README.md and docs/TRY_IT.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions