Skip to content

feat: OAuth 2.1 for streamable-HTTP MCP servers (/mcp auth) - #4

Merged
wowi42 merged 2 commits into
mainfrom
feat/mcp-oauth
Sep 8, 2026
Merged

feat: OAuth 2.1 for streamable-HTTP MCP servers (/mcp auth)#4
wowi42 merged 2 commits into
mainfrom
feat/mcp-oauth

Conversation

@Mouhand-Kaddo

Copy link
Copy Markdown
Contributor

What

Interactive OAuth 2.1 login for streamable-HTTP MCP servers (auth = "oauth" in the server config), plus the test-suite root-cause fix that had been blocking this work.

Feature

  • /mcp auth <name>: browser login via the SDK's OAuthClientProvider — loopback callback server on a per-endpoint deterministic port, PKCE, dynamic client registration, PRM → OASM discovery.
  • Startup and reconnect are credentials-only, never blocking on a browser: valid cached tokens attach; an expired access token refreshes silently (the SDK restores stored tokens but not their absolute expiry, so FileTokenStorage stamps acquisition time and the provider seeds token_expiry_time — the SDK's own refresh path then runs). A missing or rejected refresh shows authentication required — /mcp auth <name>.
  • Credentials persist per endpoint under <config_dir>/mcp-auth/ (0700 dir, 0600 files, atomic writes). /mcp logout <name> drops the session and stored credentials.
  • auth = "oauth" + a static Authorization header is rejected loudly at connect (they would conflict).
  • AS-controlled error text (denied consent, error descriptions) is whitespace-collapsed, control-char-stripped, and capped before reaching the terminal.
  • /mcp auth prints the authorization URL in the feed so a different browser can be used; if no browser can open, the error carries the URL.
  • The expected startup dead end ("No redirect handler provided") no longer splashes an SDK ERROR traceback around the TUI: automatic connects run with the mcp.client.auth loggers muted; the failure still surfaces as authentication required.

Test-suite fix — root cause of the "OAuth E2E then plain HTTP fails" flake

sse-starlette's shutdown watcher captures the uvicorn Server from the process-global SIGTERM table; when a test fixture's teardown sets should_exit and the watcher's 0.5s poll lands before that event loop closes, it flips the module-global AppStatus.should_exit (never reset by the library) — every SSE response in later tests is then cancelled at birth ("SSE stream ended without a response" / "ASGI callable returned without completing response"). Whether the poll lands in the teardown window is the flake; a loaded machine makes it look deterministic. Fixed with a per-test reset fixture in tests/conftest.py, pinned by a deterministic ordered regression pair in tests/test_mcp.py.

Tests

tests/test_mcp_auth.py (21 tests): full E2E (login, in-session refresh, restart, logout, non-interactive recovery), expired-token restart silent refresh at the attach_mcp/registry seam, rejected refresh, denied consent with control chars, no-browser fallback, loopback malformed-request and cancellation hardening; plus the interference regression pair in tests/test_mcp.py.

ruff clean. Full suite: 1105 passed — the only failures are the pre-existing tests/test_telemetry.py ones (optional opentelemetry extra not installed, files untouched by this PR).

Not yet validated against a real GlitchTip instance; the fake AS is protocol-faithful (401 → discovery → registration → PKCE → callback → refresh) but real-server quirks remain untested.

Interactive login via the SDK's OAuthClientProvider: loopback callback +
browser for /mcp auth, cached-credentials-only for startup and reconnects.
Credentials live per endpoint under <config_dir>/mcp-auth (0600/atomic).
Stored tokens are stamped with their acquisition time so an expired access
token is refreshed silently on restart (the SDK restores stored tokens but
not their absolute expiry; seeding it lets its own refresh path run).
auth_required is distinct from failed; AS-controlled error text is
control-char-stripped and capped; logout drops session + credentials.

The OAuth-E2E-then-plain-HTTP test interference was sse-starlette's
process-global AppStatus.should_exit: its shutdown watcher captures the
uvicorn server from the signal table and, when a test fixture's teardown
sets should_exit, flips the global flag and kills every later SSE response
at birth. Fixed per-test in tests/conftest.py and pinned by an ordered
regression pair in tests/test_mcp.py.
…eback

/mcp auth now prints the authorization URL in the feed right before the
browser opens, so it can be pasted into a different browser; if no browser
can be opened at all, the error carries the URL to open by hand.

The 'No redirect handler provided' OAuthFlowError the user saw is the
expected startup dead end (no credentials, no browser at startup): the
SDK logs it at ERROR with a full traceback, splashing raw stderr around
the TUI at every launch. Automatic connects now run with the
mcp.client.auth loggers muted; the failure is still classified and shown
as 'authentication required — /mcp auth <name>'.
@wowi42
wowi42 merged commit 3c901b5 into main Sep 8, 2026
4 checks passed
@wowi42
wowi42 deleted the feat/mcp-oauth branch September 8, 2026 06: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.

2 participants