Conversation
…esearch#78174) Shared gateways currently store MCP OAuth per profile+server, so Alice's token can be reused for Bob. Add mcp.oauth.identity_mode (default shared) with a fail-closed per_user mode that scopes tokens, providers, 401 refresh, live connections, breakers, and private schema-cache entries to the bound gateway principal. Empty tenant scope canonicalizes to "~"; missing identity never falls back to a shared token; hermes mcp remove cleans by-user artifacts; CLI login without a bound principal is refused. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
Circuit-breaker and 401 stubs now accept the scoped handle_401 kwargs. Manager isolation tests seed requester-scoped token files so get_or_build_provider does not require an interactive TTY. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
run_coroutine_threadsafe copies the loop thread's ContextVars, so per_user OAuth capture would fail closed (or inherit a stale principal) on a live gateway request. Re-bind the scheduling thread's principal inside the scheduled task, pin it on MCPServerTask.start before ensure_future, and never recapture on reconnect. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
invalidate_if_disk_changed now takes hermes_home and oauth_scope so the 401 path cannot re-resolve ambient identity. The concurrent-dedup stub must accept and forward those kwargs. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
Replace the bare-name live-key fallback with one fail-closed _oauth_call_target, keep manager _key a pure tuple, and resolve identity only at public API edges. Credential paths use the exact registry key; 401 recovery no longer runs against ambient shared state on a miss. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
Passing the already-resolved registry key into _ensure_lazy_server_connected broke first-use stubs that still take only the server name. Lookup still uses the fail-closed key; lazy connect re-resolves on demand. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
Unbound per_user startup now loads any matching requester-scoped schema cache so tool names survive a gateway restart. Bound re-register no longer skips a lazy template whose tools were never published. _deregister_tools keeps names still served by a sibling connection or the cache-backed template. OAuth classification is discarded on auth change and cleared on shutdown so header/none reloads do not stay requester-scoped. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
/reload-mcp no longer calls shutdown_mcp_servers over every live connection. In per_user mode a bound requester recycles only their OAuth sessions, process-level non-OAuth servers, and identities of servers removed from config — Alice cannot tear down Bob's OAuth session. Full shutdown and scoped reload now purge cache-backed lazy templates so a deleted server cannot stay callable. tools/list_changed refresh keeps a name registered while a sibling live connection still advertises it. Co-authored-by: Yu Ishikawa <yu-iskw@users.noreply.github.com>
yu-iskw
marked this pull request as ready for review
August 27, 2026 06:55
5 of 7 tasks
11 of 12 tasks
This branch has not been deployed
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 does this PR do?
Shared Hermes gateways stored MCP OAuth tokens per profile + server (
$HERMES_HOME/mcp-tokens/<server>.json). On a multi-user gateway, Alice’s GitHub (or other OAuth) credential could be reused for Bob.This PR adds an explicit
mcp.oauth.identity_modesetting:shared(default, absent key): existing layout and behavior. Single-user CLI/TUI/desktop keep working with no config change.per_user: tokens and live connections are isolated by a bound requester principal(v1, platform, scope_id, user_id)from session ContextVars only — neveros.environ, never tool arguments. Persistence keys areu-v1-+ SHA-256 of that tuple, so raw user IDs never appear in paths.per_userfails closed when no principal is bound (CLI, TUI, desktop, and cron). Invalid values such asper-userare rejected rather than silently falling back to shared. Credential lookups use an exact registry token; they never fall back to “any connection named github.”This is a native implementation of #78174, not a transplant of #79449. Headless consent UX (#78169) is out of scope.
Related Issue
Fixes NousResearch/hermes-agent#78174
Type of Change
Changes Made
tools/mcp_oauth_identity.py— typed principal/scope, fail-closed resolver, opaque persistence keys, exact registry tokensgateway/session_context.py—get_bound_session_principal()/apply_bound_session_principal(); never readsos.environfor OAuth identitytools/mcp_oauth.py—HermesTokenStoragepinshermes_home+ scope;per_userlayout undermcp-tokens/by-user/<digest>/; adminall_identitieswipe forhermes mcp removetools/mcp_oauth_manager.py— provider cache keyed by(home, server, persistence_key);_keyis a pure tuple (no ambient re-resolve)tools/mcp_tool.py— fail-closed_oauth_call_target; live maps use exact keys; MCP-loop hops re-bind the caller principal; startup does not pick a shared human credential inper_user;/reload-mcpusesreload_mcp_connections()so a boundper_userrequester cannot disconnect another principal’s OAuth sessiontools/mcp_schema_cache.py— private list/schema cache entries are principal-scoped;cacheScope=publicstays unscoped;get_startup_cached_entry()republishes tool names from any matching scoped cache without selecting credentialshermes_cli/config_defaults.py,cli-config.yaml.example,website/docs/user-guide/features/mcp.md—mcp.oauth.identity_modedocs/rfc/requester-scoped-mcp-oauth.md— locked decisionstests/tools/test_mcp_oauth_identity.py,tests/tools/test_mcp_oauth_per_user.py,tests/tools/test_mcp_loop_session_principal.pyReview follow-ups (Codex on #2)
per_userstartup loads a requester-scoped schema cache so MCP tool names survive a gateway restart (schemas only; never used as a credential selector). Bound/reload-mcpno longer skips a lazy template whose tools were never published._deregister_toolskeeps a name registered while another live connection for the same logical server still lists it, or while the cache-backed lazy template still lists it._oauth_protected_serversis add-or-discard per server in the current register batch, and is cleared onshutdown_mcp_servers(), soauth: oauth→ header/none on reload does not stay requester-scoped./reload-mcp(gateway, CLI, TUI) callsreload_mcp_connections()instead of a process-wideshutdown_mcp_servers(). Inper_userwith a bound requester, Alice’s OAuth connections, process-level non-OAuth servers, and every identity of a server removed fromconfig.yamlare recycled; Bob’s live OAuth session stays up. Shared mode and unbound CLI/TUI still take the full wipe path. Process-exit teardown still callsshutdown_mcp_servers()._deregister_toolsstarted preserving those cache names._refresh_tools(tools/list_changed) no longer globally deregisters a name another principal’s live connection still advertises. Lazy-cache names are not treated as ownership on the live-refresh path, so a truly deleted tool still drops when no sibling holds it.How to Test
mcp.oauth.identity_mode: per_user, Alice and Bob bound as different gateway requesters must get distinct token paths and must not share a live MCP connection. A call with no bound principal must fail closed rather than using Alice’s token.mcp.oauth.identity_mode(or setshared) and existing$HERMES_HOME/mcp-tokens/<server>.jsonlogin/reuse still works./reload-mcpinper_user: Alice’s reload must not close Bob’s live OAuth connection; a server removed fromconfig.yamlmust drop its lazy template and tool names.pytest;scripts/run_tests.shis CI-parity):Last run: 313 passed, 0 failed.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passThe full tree was not run as
pytest tests/ -q. The MCP OAuth / session / reload slice above was run withscripts/run_tests.sh(313 passed).Documentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A (docs/rfc/requester-scoped-mcp-oauth.mdinstead)Screenshots / Logs
N/A — isolation is covered by unit tests (
test_mcp_oauth_per_user.py,test_mcp_oauth_identity.py,test_mcp_loop_session_principal.py).