Skip to content

fix(mcp): isolate OAuth credentials by authenticated requester - #2

Closed
yu-iskw wants to merge 8 commits into
mainfrom
cursor/requester-scoped-mcp-oauth-dff6
Closed

yu-iskw wants to merge 8 commits into
mainfrom
cursor/requester-scoped-mcp-oauth-dff6

Conversation

@yu-iskw

@yu-iskw yu-iskw commented Aug 27, 2026 •

Copy link
Copy Markdown
Owner

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_mode setting:

  • 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 — never os.environ, never tool arguments. Persistence keys are u-v1- + SHA-256 of that tuple, so raw user IDs never appear in paths.

per_user fails closed when no principal is bound (CLI, TUI, desktop, and cron). Invalid values such as per-user are 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 NousResearch#78174, not a transplant of NousResearch#79449. Headless consent UX (NousResearch#78169) is out of scope.

Related Issue

Fixes NousResearch/hermes-agent#78174

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • tools/mcp_oauth_identity.py — typed principal/scope, fail-closed resolver, opaque persistence keys, exact registry tokens
  • gateway/session_context.py — get_bound_session_principal() / apply_bound_session_principal(); never reads os.environ for OAuth identity
  • tools/mcp_oauth.py — HermesTokenStorage pins hermes_home + scope; per_user layout under mcp-tokens/by-user/<digest>/; admin all_identities wipe for hermes mcp remove
  • tools/mcp_oauth_manager.py — provider cache keyed by (home, server, persistence_key); _key is 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 in per_user; /reload-mcp uses reload_mcp_connections() so a bound per_user requester cannot disconnect another principal’s OAuth session
  • tools/mcp_schema_cache.py — private list/schema cache entries are principal-scoped; cacheScope=public stays unscoped; get_startup_cached_entry() republishes tool names from any matching scoped cache without selecting credentials
  • hermes_cli/config_defaults.py, cli-config.yaml.example, website/docs/user-guide/features/mcp.md — mcp.oauth.identity_mode
  • docs/rfc/requester-scoped-mcp-oauth.md — locked decisions
  • Tests: tests/tools/test_mcp_oauth_identity.py, tests/tools/test_mcp_oauth_per_user.py, tests/tools/test_mcp_loop_session_principal.py

Review follow-ups (Codex on #2)

  • Unbound per_user startup loads a requester-scoped schema cache so MCP tool names survive a gateway restart (schemas only; never used as a credential selector). Bound /reload-mcp no longer skips a lazy template whose tools were never published.
  • _deregister_tools keeps 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_servers is add-or-discard per server in the current register batch, and is cleared on shutdown_mcp_servers(), so auth: oauth → header/none on reload does not stay requester-scoped.
  • /reload-mcp (gateway, CLI, TUI) calls reload_mcp_connections() instead of a process-wide shutdown_mcp_servers(). In per_user with a bound requester, Alice’s OAuth connections, process-level non-OAuth servers, and every identity of a server removed from config.yaml are recycled; Bob’s live OAuth session stays up. Shared mode and unbound CLI/TUI still take the full wipe path. Process-exit teardown still calls shutdown_mcp_servers().
  • Full shutdown (including the no-live-servers fast path) and scoped reload purge cache-backed lazy templates so a server deleted from config cannot remain callable after _deregister_tools started 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

  1. Confirm the confused-deputy class is covered: with 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.
  2. Confirm the default is unchanged: omit mcp.oauth.identity_mode (or set shared) and existing $HERMES_HOME/mcp-tokens/<server>.json login/reuse still works.
  3. Confirm /reload-mcp in per_user: Alice’s reload must not close Bob’s live OAuth connection; a server removed from config.yaml must drop its lazy template and tool names.
  4. Run the canonical suite (not raw pytest; scripts/run_tests.sh is CI-parity):
HERMES_PYTHON=/usr/bin/python3 scripts/run_tests.sh \
  tests/tools/test_mcp_oauth_identity.py \
  tests/tools/test_mcp_oauth_per_user.py \
  tests/tools/test_mcp_loop_session_principal.py \
  tests/tools/test_mcp_oauth.py \
  tests/tools/test_mcp_oauth_manager.py \
  tests/tools/test_mcp_oauth_integration.py \
  tests/tools/test_mcp_schema_cache.py \
  tests/tools/test_mcp_circuit_breaker.py \
  tests/tools/test_mcp_tool_401_handling.py \
  tests/tools/test_mcp_lazy_start.py \
  tests/tools/test_mcp_dynamic_discovery.py \
  tests/tools/test_mcp_tool.py \
  tests/tools/test_mcp_bridge_single_failure.py \
  tests/gateway/test_mcp_reload_refreshes_cached_agents.py \
  tests/tui_gateway/test_mcp_reload_rev.py \
  tests/gateway/test_session_env.py -q

Last run: 313 passed, 0 failed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux (cloud agent)

The full tree was not run as pytest tests/ -q. The MCP OAuth / session / reload slice above was run with scripts/run_tests.sh (313 passed).

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A (docs/rfc/requester-scoped-mcp-oauth.md instead)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (no new core tools; no model-visible credential selector)

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).

Open in Web Open in Cursor 

cursoragent and others added 2 commits August 27, 2026 00:30
…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>
@cursor cursor Bot changed the title fix(mcp): isolate OAuth credentials by authenticated requester fix(mcp): isolate OAuth credentials by authenticated requester (#78174) Aug 27, 2026
cursoragent and others added 2 commits August 27, 2026 00:46
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>
@yu-iskw
yu-iskw marked this pull request as ready for review August 27, 2026 00:48
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

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>
@cursor cursor Bot changed the title fix(mcp): isolate OAuth credentials by authenticated requester (#78174) fix(mcp): isolate OAuth credentials by authenticated requester Aug 27, 2026
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>
@yu-iskw

yu-iskw commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

@codex[agent] review

@Codex

Codex AI commented Aug 27, 2026

Copy link
Copy Markdown

@codex[agent] review

Reviewed the current PR state and latest 6 commits; I don’t see an additional change needed from my side at this point. Reviewed head: 39f0350.

@yu-iskw yu-iskw closed this Aug 27, 2026
@yu-iskw yu-iskw reopened this Aug 27, 2026
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39f0350c5c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/mcp_tool.py Outdated
and name not in _lazy_server_tool_names
):
try:
entry = get_cached_entry(name, config_fingerprint(cfg))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load requester-scoped caches once a principal is bound

On a restarted per_user gateway where the only manifest is the normal private cache written by _register_server_tools, this unbound startup lookup always uses the bare server-name key, while the cache was stored under the requester's registry token. The server is then placed in _lazy_server_configs, and later discovery under that bound requester skips it at if k in _lazy_server_configs; because no handler was registered, no first tool call can trigger the live connection. Consequently a user with valid scoped tokens/cache loses all MCP tools after restart unless an old unscoped or explicitly public cache happens to exist. The startup-to-bound-session path needs to load the requester's scoped cache or connect eagerly.

AGENTS.md reference: AGENTS.md:L84-L87

Useful? React with 👍 / 👎.

Comment thread tools/mcp_tool.py
_server_connect_errors.pop(live_key, None)
_server_connect_errors.pop(name, None)
_servers[name] = server
_servers[live_key] = server

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep tools registered while another principal remains live

Once this registry stores simultaneous Alice and Bob connections for the same logical server, both tasks record the same global tool names. If Alice's connection exhausts its reconnect budget, its unchanged _deregister_tools() removes those names from the process-wide registry and provenance map even while Bob's connection remains healthy; Bob also keeps a nonempty _registered_tool_names, so his reconnect path will not republish them. New sessions—and refreshed existing sessions—therefore lose a healthy user's MCP tools because another user's connection failed. Tool deregistration needs per-principal ownership/refcounting or must preserve names while any same-name connection is live.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

Comment thread tools/mcp_tool.py
Comment on lines +7931 to +7933
for srv_name, srv_cfg in servers.items():
if server_uses_oauth(srv_cfg):
_oauth_protected_servers.add(srv_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear OAuth classification when auth config changes

When a server is changed from auth: oauth to header/no authentication and /reload-mcp runs in the same process, this set retains the server name because neither reload nor shutdown_mcp_servers() clears it. _mcp_server_uses_oauth() subsequently treats the new non-OAuth config as OAuth due to the stale membership; in per_user mode, reload discovery without a bound principal defers the server entirely, and bound calls incorrectly create requester-scoped connections. Rebuild or remove entries from this classification on config reload so the documented process-level behavior for static-header servers is restored.

AGENTS.md reference: AGENTS.md:L84-L87

Useful? React with 👍 / 👎.

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>
@yu-iskw

yu-iskw commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

@codex[agent] review

@Codex

Codex AI commented Aug 27, 2026

Copy link
Copy Markdown

@codex[agent] review

Ran another review pass on the latest 7 commits; I don’t see additional changes needed from my side right now. Reviewed head: b8eaa3e.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8eaa3ed96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/mcp_tool.py
_server_connect_errors.pop(live_key, None)
_server_connect_errors.pop(name, None)
_servers[name] = server
_servers[live_key] = server

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope gateway reloads to the requesting principal

When Alice and Bob both have live per-user OAuth connections, this process-wide registry now contains both entries, but the gateway /reload-mcp path still calls shutdown_mcp_servers() over every registry value (gateway/run.py:23923). Thus either authenticated requester can disconnect the other requester's credential-bearing session, contradicting the isolation contract and disrupting concurrent calls; the reload path must avoid shutting down unrelated principal entries or be restricted to a trusted global administrator.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

Comment thread tools/mcp_tool.py
"""Pop lazy templates only in shared mode. Returns (fingerprint, names)."""
from tools.mcp_oauth_identity import IDENTITY_MODE_PER_USER, configured_identity_mode

if configured_identity_mode() == IDENTITY_MODE_PER_USER:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Purge retained lazy templates during full reload

When a per-user OAuth server has published cached tools and is subsequently deleted from config.yaml, this branch retains its lazy config and tool names indefinitely. shutdown_mcp_servers() does not clear the lazy maps, and _deregister_tools() preserves names found there, so /reload-mcp leaves the deleted tool handler registered; invoking it can reconnect to the removed endpoint using the stale config. Retention is needed between principals during normal operation, but a full shutdown/reload must explicitly deregister and purge these templates.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

Comment thread tools/mcp_tool.py Outdated
from tools.mcp_oauth_identity import is_registry_key_for_server

logical = self.name
keep = set(_lazy_server_tool_names.get(logical) or [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve sibling-owned tools during list refresh

When two principals receive different private tool manifests, this ownership protection only covers _deregister_tools(). The existing _refresh_tools() path still globally deregisters every name removed by one connection (tools/mcp_tool.py:2800-2810), so Alice receiving tools/list_changed can remove a tool that Bob's live connection still advertises. Apply the same sibling/lazy ownership check to refresh-time stale-name removal so one requester's manifest update cannot break another requester's tools.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

/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 yu-iskw closed this Aug 27, 2026
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.

[Feature]: Associate MCP OAuth authorization with the requesting user

3 participants