feat: OAuth 2.1 for streamable-HTTP MCP servers (/mcp auth) - #4
Merged
Conversation
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>'.
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.
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'sOAuthClientProvider— loopback callback server on a per-endpoint deterministic port, PKCE, dynamic client registration, PRM → OASM discovery.FileTokenStoragestamps acquisition time and the provider seedstoken_expiry_time— the SDK's own refresh path then runs). A missing or rejected refresh showsauthentication required — /mcp auth <name>.<config_dir>/mcp-auth/(0700 dir, 0600 files, atomic writes)./mcp logout <name>drops the session and stored credentials.auth = "oauth"+ a staticAuthorizationheader is rejected loudly at connect (they would conflict)./mcp authprints the authorization URL in the feed so a different browser can be used; if no browser can open, the error carries the URL.mcp.client.authloggers muted; the failure still surfaces asauthentication required.Test-suite fix — root cause of the "OAuth E2E then plain HTTP fails" flake
sse-starlette's shutdown watcher captures the uvicorn
Serverfrom the process-global SIGTERM table; when a test fixture's teardown setsshould_exitand the watcher's 0.5s poll lands before that event loop closes, it flips the module-globalAppStatus.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 intests/conftest.py, pinned by a deterministic ordered regression pair intests/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 theattach_mcp/registry seam, rejected refresh, denied consent with control chars, no-browser fallback, loopback malformed-request and cancellation hardening; plus the interference regression pair intests/test_mcp.py.ruffclean. Full suite: 1105 passed — the only failures are the pre-existingtests/test_telemetry.pyones (optionalopentelemetryextra 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.