Files
pad/docs
xarmian 202f53daef fix(mcp): MCP's long-lived streams end with their credential (BUG-3837) (#2201)
* fix(mcp): MCP's long-lived streams end with their credential, with an OAuth liveness door (BUG-3837)

The GET stream and a subscriptions/listen POST never re-checked the
credential that opened them, so a signed-out session, a revoked PAT or a
revoked OAuth grant kept receiving notifications until the client hung up
(the BUG-3007 rule in docs/api.md).

- The MCP OAuth accept point records authKindOAuth. credentialLiveness's
  new door re-introspects the token: ErrInactiveToken (a revoked token or
  grant), ErrNotFound and ErrTokenExpired end the stream; any other error
  (fosite wraps storage failures in the same outer error) is "could not
  ask" and keeps it, as the session and PAT doors do.
- mcpBoundWrites runs a jittered tick, at the /events/stream cadence, on
  the doors that took an admission slot. It ends the stream when the
  credential is invalid or its user is deleted or disabled, by cancelling
  the context mcp-go leaves on; the existing defers stop the keepalive and
  release the slot.
- docs/api.md's credential rule lists the MCP streams and says an OAuth
  stream ends when its access token expires.

Tests: a revoked PAT and a revoked OAuth grant each end a GET and a listen;
an expired token ends a stream; a live OAuth stream survives 20 ticks; a
disabled user ends an OAuth stream; a store failure keeps one. Six mutants,
all red.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): the credential tick reads the bearer as admission does; r1 fixes (BUG-3837)

r1 blind hunter:
- M1: the PAT and OAuth doors trimmed "Bearer " case-sensitively, while
  admission's extractBearer takes any scheme case (RFC 6750). A PAT stream
  sent as "bearer <tok>" closed on every tick. Both doors use extractBearer.
- L1: GetUser returns (nil, nil) for a missing row; the ErrNoRows branch was
  dead and is removed.
- L2: the expiry test's lifespan leaves setup seconds of margin.
- L3: the tick's interval and first delay are read on the handler goroutine;
  tests swap both package variables and the tick goroutine is not joined.
- L4/L5: stale test comments updated; api.md says a refresh rotation ends an
  OAuth stream, and that no revocation kicks an MCP stream at once.
New tests: a deleted token row ends the stream (M8 was alive); lowercase
"bearer" streams stay open and still end on revocation.

Codex r1's Medium (errors.Is cannot see ErrInactiveToken through fosite's
ErrRequestUnauthorized wrapper) is refuted by measurement: removing the
ErrInactiveToken case fails the revoked-grant test (M7).

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): a deleted user is not still valid; the tick skips a stream that already ended (BUG-3837)

r2: Codex's Medium (GetUser wraps sql.ErrNoRows, so a deleted user would
read as "could not ask") is refuted by a test that pins the premise:
GetUser returns (nil, nil) for a missing row, and the principal check
reports the stream invalid (red on a nil-means-valid mutant). Hunter r2
Lows: api.md no longer claims MCP clients reconnect on their own, and a
timer that fires together with the stream's end no longer logs a
credential invalidation.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): a refresh rotation ends a live stream at a frame boundary, and the new token opens one (BUG-3837)

Pins the lead's KEEP ruling on rotation: the old access token's stream ends
within one tick of the refresh, its body is whole frames, and the
rotated-in token opens a fresh stream that stays open.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): the tick skips tool calls and keeps a stream on a user read failure; r3 fixes (BUG-3837)

The OAuth token is minted last in the fixture, the rotation test's bound is
two ticks plus slack, and the comments and api.md no longer count three tick
sites or claim the dispatcher's requests carry no OAuth kind. A listen ends
with an ordinary result, which api.md now says.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): the listen tests send a modern-era listen, the only kind admission counts (BUG-3837)

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): a transient access-token store error is not read as revocation, pinned against real fosite (BUG-3837)

r4 (lead ruling): the decline of Codex's Medium rests on fosite v0.49.0
returning the access lookup's error when the refresh fallback also fails.
A fosite bump could flip that silently, so the OAuth door is driven through
real introspection with the access store failing and the fallback finding
nothing (unknown, the stream is kept), and with the access row deleted
(invalid, the stream ends) as the control.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 14:37:04 -04:00
..