fix(mcp-oauth): eliminate _oauth_port global, defer server binding, restore fail-fast guards - #54325
fix(mcp-oauth): eliminate _oauth_port global, defer server binding, restore fail-fast guards#54325lennney wants to merge 10 commits into
Conversation
Introduce OAuthCallbackServer that binds HTTPServer at construction, eliminating the TOCTOU gap between port discovery and actual server startup. Features: - Bind-first: server is live before _configure_callback_port returns - Path filtering: only /callback processed, other paths get 404 - Persistent: handle_request() loop until callback or timeout - Clean shutdown via close() 3 new tests: bind+connect, thread lifecycle, close+port release. Refs: #34260
Competing fix cluster for the
Same root cause, genuinely different mechanism — flagging for a maintainer to pick the closure-scoping vs global-elimination approach. Not marking any as duplicate. |
|
Thanks for the triage, @alt-glitch. A quick note on the architectural choice between the two approaches: The closure-scoping approach (#34280) keeps the legacy This PR (#54325) follows the bind-first pattern described in the issue's "Suggested fix direction" section, modeled on Claude Code's
Happy to adjust scope or split further if the maintainer prefers a smaller diff. The |
…ckServer Instead of finding a port with _find_free_port() (bind→close→remember), creates and starts an OAuthCallbackServer immediately. The server is bound and serving before the function returns, eliminating the TOCTOU gap. Stores server instance in cfg['_callback_server']. Refs: #34260
…OCTOU Rewrite _wait_for_callback to accept an optional OAuthCallbackServer parameter. When provided, polls the pre-bound server instead of creating a new HTTPServer on the port — eliminating the TOCTOU gap where port could be stolen between _configure_callback_port and _wait_for_callback. Changes: - _wait_for_callback(server=...) polls server.wait() + paste race - _redirect_handler accepts port param (falls back to _oauth_port) - build_oauth_auth uses functools.partial for both handlers - _build_provider uses functools.partial for both handlers - Legacy _oauth_port path preserved for backward compat Refs: #34260
…ckServer Verify: - /callback?code=... returns 200 and sets auth_code - /favicon.ico returns 404, server stays alive for next request - Existing paste integration and skip tests adapt to new server param Refs: #34260
- _paste_callback_reader: loop with up to 5 retries on invalid input - Remove _oauth_port module-level global (replaced by OAuthCallbackServer) - Remove _make_callback_handler (replaced by OAuthCallbackServer._make_handler) - Remove legacy path in _wait_for_callback (server param now required) - _redirect_handler: use port parameter directly, no fallback Tests will be updated by subagent — known breakage in paste/skip tests. Refs: #34260
…ndling - Remove _make_callback_handler dead function (replaced by OAuthCallbackServer) - Remove stale global _oauth_port statement from _configure_callback_port - Add ValueError to _serve_loop exception handler (prevents unhandled thread exception when close() interrupts handle_request()) Review: APPROVED after fixes
…kServer Replace _make_callback_handler() calls (deleted in PR2) with OAuthCallbackServer._make_handler() instances. Each test properly cleans up the server via .close().
The _serve_loop previously continued looping for up to 300s after close() called server_close(), because handle_request() exceptions were caught and the deadline had not expired. The thread.join(2.0) often raced past the 1.0s handle_request() timeout, causing test_close_stops_server to fail flakily. Add threading.Event() as a stop signal: close() sets it before server_close(), and _serve_loop checks it in the loop condition. Increase join timeout to 5s for safety margin.
0fcd2f8 to
425d54c
Compare
|
Removed workspace files (.codegraph/.gitignore, docs/plans/) from the changeset. The branch has been force-pushed with only 3 source files remaining in the diff. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for pursuing the bind-first approach; current main still has the global-port/TOCTOU premise described in #34260.
Problems
- PR head binds at
tools/mcp_oauth.py:787-790during provider construction, but removes current main’s non-interactive redirect/callback guards (tools/mcp_oauth.py:547-560,633-647, commit755194ffe). A cached-but-unusable token can therefore bind a listener in gateway, cron, or background discovery, regressing the current fail-fast contract covered bytests/tools/test_mcp_oauth.py:597-680. OAuthCallbackServer.close()has no production caller. The provider retains the server via callback partials (tools/mcp_oauth.py:905-906), while_wait_for_callback()awaits it without afinally(tools/mcp_oauth.py:654) and_serve_loop()does not close the socket on completion (tools/mcp_oauth.py:528-538).
Suggested changes
- Retain the current non-interactive guards and create/bind the per-flow server only after crossing that interactive boundary.
- Give the server an explicit lifecycle: close it on every callback outcome and provider eviction, with manager-path tests for non-interactive cached credentials and configured-port reuse.
Automated hermes-sweeper review.
| cfg["_resolved_port"] = port | ||
| _oauth_port = port # legacy consumer: _wait_for_callback reads this | ||
| return port | ||
| server = OAuthCallbackServer(port=requested) |
There was a problem hiding this comment.
This now binds during provider construction, before current main's non-interactive redirect/callback guards. MCPOAuthManager.get_or_build_provider() is called from tools/mcp_tool.py:2365; cached-but-unusable tokens would again bind a listener in gateway/cron/background contexts, regressing commit 755194ffe. Preserve that guard and defer binding until the interactive authorization path.
| threading.Thread( | ||
| target=_paste_callback_reader, args=(server._result,), daemon=True | ||
| ).start() | ||
| result = await server.wait() |
There was a problem hiding this comment.
Please close the callback server in a finally. No production path calls OAuthCallbackServer.close(): the server is retained by the callback partials at lines 905-906, and _serve_loop() exits without server_close(). This can leave an explicit redirect_port occupied after success, failure, or timeout.
…erver lifecycle PR review by @teknium1 identified two regressions in the bind-first approach: 1. Port binding during provider construction bypasses non-interactive guards - _configure_callback_port() now resolves port only (no OAuthCallbackServer bind) - _redirect_handler and _wait_for_callback regain _raise_if_non_interactive() checks - Server (OAuthCallbackServer) is created only after the interactive guard passes - Gateway/cron/background contexts fail fast before consuming a port 2. OAuthCallbackServer.close() had no caller — server leaked on success/failure/timeout - _wait_for_callback now creates server internally with finally: server.close() - Clean lifecycle: bind → start → try/wait → finally/close Also removes cfg['_callback_server'] (no longer stored) — port is passed via partial. Updates tests: restore TestNonInteractiveFailFastAtCallbackBoundary, migrate all _wait_for_callback(server=server) callers to _wait_for_callback(port=port).
authorize_via_loopback() now checks _can_display_browser() before binding a port on :8765. If open_url defaults to webbrowser.open (the dashboard path) but no display/desktop is available, fails fast with an actionable error instead of blocking for 300s. Same bug class as #57836 (MCP OAuth non-interactive guard) adapted for the Honcho flow — always user-initiated, so the guard is environmental (no display) rather than stdin-interactivity based. CLI callers that pass a custom open_url (which prints the URL + webbrowser) are unaffected.
What does this PR do?
Fixes #34260, preserves #57836 fail-fast contract.
Eliminates the module-level
_oauth_portglobal that caused TOCTOU race and concurrent-flow port collision. IntroducesOAuthCallbackServer(bind-first class) but defers binding until the interactive guard passes, so gateway/cron/background contexts never consume a port for an unusable cached token.This PR addresses review feedback from @teknium1 (hermes-sweeper) which identified two regressions in the original bind-first approach.
Related Issue
Fixes #34260
Type of Change
Changes Made
Non-interactive fail-fast guards restored (
tools/mcp_oauth.py)_redirect_handler()regains_raise_if_non_interactive()-- rejects before printing URL_wait_for_callback()regains_raise_if_non_interactive()-- rejects before bindingOAuthCallbackServerTestNonInteractiveFailFastAtCallbackBoundary(6 tests) restored -- verify no listener is bound in non-interactive contextsPort resolution separated from server binding
_configure_callback_port()now only resolves the port (returnsint) -- no HTTPServer createdOAuthCallbackServeris created inside_wait_for_callback()after the non-interactive guard passes_oauth_portglobal is still set as a bridge for existing consumersServer lifecycle management
_wait_for_callback()createsOAuthCallbackServer, callsserver.start(), thenawait server.wait()inside atryblock, withserver.close()infinallyOAuthCallbackServer.close()is always called on success, failure, or timeoutcfgdict or retained via callback partials after the flow completesOAuthCallbackServer (kept from original PR)
HTTPServerat construction (when called inside_wait_for_callback)handle_request()until callback or timeoutthreading.Event()stop signal/callbackprocessed, others get 404Callers updated
build_oauth_auth(tools/mcp_oauth.py): passesportviafunctools.partialinstead ofserver_build_provider(tools/mcp_oauth_manager.py): same -- passesportvia partial_redirect_handler: accepts optionalportparamHoncho OAuth: display-guard added (
plugins/memory/honcho/oauth_flow.py)authorize_via_loopback()now checks_can_display_browser()before binding port :8765open_urlare unaffectedFiles changed
tools/mcp_oauth.py-- core changetools/mcp_oauth_manager.py-- caller updatetests/tools/test_mcp_oauth.py-- restored + migrated testsplugins/memory/honcho/oauth_flow.py-- display guardHow to Test
TestNonInteractiveFailFastAtCallbackBoundary(6 tests) -- verify fail-fast before binding_wait_for_callbacktests migrated fromserver=toport=paramhermes mcp login <server>with cached-but-expired tokens -- verify fail-fast in non-interactive mode, success in interactive modeSecurity
The fail-fast contract (#57836) is now enforced at three layers:
_is_interactive() and not storage.has_cached_tokens()-- no cached tokens in non-interactive env_redirect_handler-- cached-but-unusable token caught before URL printed_wait_for_callback-- cached-but-unusable token caught before port boundChecklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/tools/test_mcp_oauth.pyand all tests pass (84/84)Documentation & Housekeeping