[ROB-2694] Oauth - #1900
[ROB-2694] Oauth#1900
Conversation
Implements full OAuth authorization_code flow for MCP toolsets: - Frontend redirects user to IdP, receives auth code - Auth code encrypted with Holmes RSA public key, sent via save_prefixes - Holmes decrypts code, exchanges for token at cluster-internal token_url - Tokens cached per conversation with TTL, auto-evicted on expiry - Access token never leaves the cluster Includes Keycloak + MCP server test infrastructure in K8s for end-to-end OAuth flow verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Cache key now includes IdP identity (authorization_url + client_id), so MCP servers sharing the same IdP reuse one token per conversation - Fix MCP server crash when token has multiple audiences (list vs string) - Add _find_matching_audience to return correct audience string - Add WARNING-level logs for all token operations (inject, refresh, cache, errors) - Add tests for shared IdP caching and cache key isolation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Add enabled field to MCPOAuthConfig (auto-set when endpoints configured)
- Auto-discover OAuth endpoints: PRM (RFC 9728) + legacy fallback + DCR
- CLI mode: opens browser + local callback server for OAuth login
- Disk token store (~/.holmes/auth/mcp_tokens.json) for CLI persistence
- Dual-mode detection: CLI (no request_context) vs frontend (has headers)
- Live-tested against Atlassian MCP server (31 tools discovered)
- Config is just: oauth: { enabled: true } for auto-discovery servers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…ders The previous detection checked for specific header names (X-Conversation-Id, etc.) which the frontend doesn't always send. Now uses request_context != None as the signal — frontend always passes request_context, CLI never does. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Don't do DCR at startup (redirect_uri unknown, causes mismatch) - Send registration_endpoint in __oauth_metadata so frontend can DCR with its own redirect_uri - Only include scopes in metadata when non-empty (fixes scope= in URL) - Frontend must: check registration_endpoint, DCR if no client_id, omit scope param when scopes not provided Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
When DCR is deferred to frontend (e.g. Atlassian), the frontend includes
client_id in the encrypted {code, redirect_uri, client_id} payload.
Holmes uses it for the token exchange. Backwards compatible — falls back
to oauth_config.client_id when client_id is not in the payload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Generate RSA keypair once, persist it encrypted with a Fernet key derived from signing_key via HKDF. Same signing_key always produces the same keypair (loaded from disk), surviving Holmes restarts. Falls back to random keypair in CLI mode (no signing_key). - Singleton pattern via get_oauth_key_exchange() - Encrypted at rest with signing_key-derived Fernet key - Graceful fallback when filesystem is read-only - Tests verify: same key -> same keypair (5x), different keys -> different keypairs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
CLI mode now persists the RSA keypair to ~/.holmes/auth/oauth_keypair.enc encrypted with a Fernet key derived from machine identity (hostname + username). File permissions set to 600 (owner-only). This means CLI users don't need to re-authenticate after Holmes restarts. Server mode: encrypted with signing_key (unchanged) CLI mode: encrypted with machine identity, stored in user's home dir Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
CLI mode now runs DCR inside _cli_oauth_flow after the callback server starts (so the actual port is known for redirect_uri). Fixes Atlassian CLI flow where client_id is None after deferred discovery. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Recompute the OAuth cache key after _cli_oauth_flow returns, since DCR may have changed client_id (e.g. Atlassian auto-discovery). Without this, the token was cached under the pre-DCR key but looked up under the post-DCR key, causing a 401. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Tests cover: - Full roundtrip: DCR → browser open → callback → token exchange - DCR when client_id is None (Atlassian scenario) - Cache key changes after DCR sets client_id - Failure cases: missing endpoints, no DCR, token exchange failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Migration: OAuthTokens table with RLS (account-level access) - DAL methods: get_oauth_token, upsert_oauth_token, delete_oauth_token - Token encrypted at rest with signing_key-derived Fernet key - Signing key hash stored for mismatch detection across clusters - Flow: check in-memory cache → check DB → check disk → prompt user - On successful auth: store to in-memory cache + DB + disk - Warning logged when signing_key_hash mismatch detected (different cluster/config) - set_oauth_dal() wired into create_tool_executor at startup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
In-cluster containers have read-only root filesystem. DiskTokenStore now catches OSError during init and disables itself. All get/set/has methods become no-ops when disabled. DB storage is used instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…set name Tokens are stored with provider_name=authorization_url but looked up by toolset name. Now both store and lookup use authorization_url consistently. Also added diagnostic log when no DB record found. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- token_expiry now tracks refresh token expiry (not access token), since that's what matters for cross-cluster reuse - DB row updated when token is refreshed, keeping it fresh for other clusters Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Relay injects verified user_id (from session token) into action_params for Holmes actions. Holmes propagates it through request_context to the MCP OAuth layer, where it scopes the in-memory cache key, DB storage, and DB lookups. This ensures OAuth tokens are per-user, not per-account. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Add Cloudflare-themed MCP server eval with Keycloak OAuth behind auto-discovery via /.well-known/oauth-protected-resource (RFC 9728) - before_test pre-obtains token via direct access grant and injects into ~/.holmes/auth/mcp_tokens.json for non-interactive eval flow - MCP server serves PRM + OAuth metadata endpoints via custom Starlette routes, bridging Holmes discovery with Keycloak's URL structure - Consolidate test_oauth_extras.py into test_mcp_oauth.py (80 tests): token cache refresh, DiskTokenStore, DB encryption, _try_refresh_token, _inject_oauth_token, _discover_oauth_endpoints, ToolExecutor dynamic tools/prefix stripping, OAuthKeyExchange edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…tool preloading Replace RSA-encrypted auth code transit with plaintext JSON (frontend sends auth code directly). Extract OAuthTokenManager and token stores into dedicated modules. Add per-request tool executor with preloaded OAuth MCP tools so authenticated users get real tools without re-authenticating each request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Extract shared exchange_code_for_tokens() into holmes/core/oauth_utils.py, replacing 3 duplicate HTTP POST implementations (server.py, toolset_mcp.py x2). server.py now uses in-memory toolset config instead of DB round-trip via dal.get_toolset_oauth_config(), and stores tokens via OAuthTokenManager (encrypted + auto-refresh) instead of raw dal.upsert_oauth_token(). Extract _get_toolset_oauth_config() helper shared by process_oauth_callback and store_oauth_token endpoints. Mark TestLiveAtlassianOAuthDiscovery as @pytest.mark.manual (auto-skipped unless -m manual is passed) to prevent browser-opening tests in CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…s, simplify token manager Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds end-to-end OAuth for MCP toolsets: discovery, PKCE/authorization-code exchange, per-conversation/user token caching (memory, disk, DB), background refresh, dynamic tool loading post-auth, server callback endpoint, DAL wiring, docs, many tests, and a pytest hook to skip Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(150,200,250,0.5)
participant User as Browser (User)
participant Holmes as Holmes Server
participant TokenMgr as OAuth Token Manager
participant DB as Supabase DB
participant OAuth as OAuth Provider
participant MCP as MCP Server
end
User->>Holmes: Trigger tool requiring OAuth (includes user_id)
Holmes->>TokenMgr: preload_oauth_mcp_tools(user_id, convo)
TokenMgr->>TokenMgr: check cache/disk/DB for token
alt token missing
TokenMgr->>DB: get_oauth_token(...)
DB-->>TokenMgr: encrypted token (if any)
TokenMgr->>TokenMgr: decrypt & cache
end
alt user needs auth
Holmes->>User: present "connect" placeholder / PKCE link
User->>OAuth: Authorize (browser)
OAuth-->>Holmes: Auth code -> POST /api/oauth/callback
Holmes->>TokenMgr: process_oauth_callback(code)
TokenMgr->>OAuth: POST token_url (code exchange)
OAuth-->>TokenMgr: access_token/refresh_token
TokenMgr->>DB: upsert_oauth_token(encrypted)
end
Holmes->>MCP: Call tool with Authorization: Bearer <access_token>
MCP-->>Holmes: Tool response
sequenceDiagram
rect rgba(220,180,180,0.5)
participant TokenMgr as OAuth Token Manager
participant BG as Background Refresh Thread
participant OAuth as OAuth Provider
participant DB as Supabase DB
end
TokenMgr->>BG: schedule refresh (expires_in - safety_buffer)
Note over BG: wakes periodically (~60s)
BG->>BG: check due refreshes
alt refresh due
BG->>OAuth: POST token_url (refresh_token grant)
alt success
OAuth-->>BG: new access_token
BG->>TokenMgr: update cache
BG->>DB: upsert_oauth_token(encrypted)
else failure
BG->>BG: log failure, possibly retry/remove schedule
end
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~110 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ Deploy Preview for holmes-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Actionable comments posted: 17
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
holmes/core/tool_calling_llm.py (1)
250-306:⚠️ Potential issue | 🔴 CriticalThe approved OAuth retry is broken before it ever sees the token.
ToolApprovalDecisionno longer has anencrypted_tokenfield, so the log at Line 250 and the branch at Line 294 will raiseAttributeErroras soon as approvals are processed. Even after that is fixed, the only supported OAuth payload now lives indecision.decision, but this code handles it after_invoke_llm_tool_call(), so the approved retry runs once without the exchanged token.💡 Suggested direction
logging.warning("OAuth flow: received %d tool decisions: %s", len(tool_decisions), - [(d.tool_call_id, d.approved, bool(d.encrypted_token)) for d in tool_decisions]) + [(d.tool_call_id, d.approved, bool(d.decision)) for d in tool_decisions]) ... if decision and decision.approved: - # Exchange OAuth auth code for token if present (from frontend browser OAuth flow). - # The OAuth payload is passed via the encrypted_token field or save_prefixes - # with a "__oauth_token__:" marker. - oauth_payload = None - if decision.encrypted_token: - oauth_payload = decision.encrypted_token - decision.encrypted_token = None - elif decision.save_prefixes: - for prefix in list(decision.save_prefixes): - if prefix.startswith("__oauth_token__:"): - oauth_payload = prefix[len("__oauth_token__:"):] - decision.save_prefixes.remove(prefix) - break - - if oauth_payload: - logging.warning("OAuth: received auth code for tool_call_id=%s, exchanging for token", tool_call.id) - exchange_code_for_token(tool_call.id, oauth_payload, request_context) + if decision.decision: + _try_process_oauth_decision(decision.decision) tool_result = self._invoke_llm_tool_call( tool_to_call=tool_call, previous_tool_calls=[], trace_span=trace_span, tool_number=None, user_approved=True, session_approved_prefixes=session_prefixes, request_context=request_context, enable_tool_approval=True, ) - # If the decision contains OAuth callback data, process token exchange - if decision and decision.approved and decision.decision: - _try_process_oauth_decision(decision.decision)Also applies to: 338-340
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/tool_calling_llm.py` around lines 250 - 306, The code reads decision.encrypted_token (which no longer exists) and logs it, causing AttributeError; change logging and token-extraction to use the current payload location (decision.decision or entries in decision.save_prefixes with "__oauth_token__:"), avoid accessing encrypted_token, and extract the OAuth payload before any retry/invocation so the exchanged token is available; specifically update the handling in the loop over pending_tool_calls (ToolCallWithDecision, tool_call, decision) to (1) stop referencing decision.encrypted_token in the logging and extraction, (2) check decision.decision first for an OAuth payload and fallback to scanning decision.save_prefixes for "__oauth_token__:", remove that prefix when used, call exchange_code_for_token(tool_call.id, payload, request_context) immediately before invoking the tool retry path (so the token is present for _invoke_llm_tool_call), and audit any other references to encrypted_token in this function (including the other occurrence noted) to apply the same fix.
🧹 Nitpick comments (1)
tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp_oauth_server.py (1)
164-169: Bring the new fixture back in line with the repo’s Python conventions.
KeycloakTokenVerifier.__init__introduces untyped parameters, andadd_wellknown_routes()adds function-local imports. Please type the constructor inputs (str) and move the Starlette imports to module scope.As per coding guidelines, "Type hints are required throughout the codebase" and "ALWAYS place Python imports at the top of the file, not inside functions or methods".
Also applies to: 293-295
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp_oauth_server.py` around lines 164 - 169, KeycloakTokenVerifier.__init__ currently has untyped parameters and add_wellknown_routes() contains function-local Starlette imports; update the constructor signature for KeycloakTokenVerifier to annotate all parameters as str (e.g., introspection_endpoint: str, server_url: str, client_id: str, client_secret: str) and ensure any other constructors in this file follow the same typing (also apply to the other constructor around the indicated area). Move all Starlette imports used in add_wellknown_routes() (e.g., Starlette, Route, JSONResponse, PlainTextResponse or whatever is imported there) to the module top-level instead of importing inside the function, keeping their original names so callers in add_wellknown_routes() continue to work. Ensure imports remain at top of file and that type hints are added consistently across similar fixtures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/data-sources/remote-mcp-servers.md`:
- Around line 351-430: Update the OAuth docs to scope token lifetime and storage
by deployment mode: replace the blanket statements "access token is cached for
the conversation" and "The access token never leaves the cluster" with explicit
notes that in CLI mode tokens may be persisted locally (e.g., in the user's CLI
config or OS credential store / ~/.holmes/config.yaml) and in server mode tokens
may be stored in the configured DAL-backed credential storage, while still
ensuring that the authorization code is exchanged server-side at `token_url` and
that `authorization_url` remains browser-accessible; reference the
`authorization_url`, `token_url`, `client_id`, and `scopes` fields when
describing these mode-specific storage behaviors.
In `@holmes/config.py`:
- Around line 312-317: The cached-executor early return prevents
set_oauth_dal(dal) from being called on subsequent create_tool_executor() calls,
so call set_oauth_dal(dal) before returning the cached executor: inside
create_tool_executor(), invoke set_oauth_dal(dal) (or set it only when dal is
not None, if desired) immediately before the if self._server_tool_executor:
return self._server_tool_executor branch so the OAuth DAL is refreshed on every
call even when reusing the cached _server_tool_executor.
In `@holmes/core/oauth_utils.py`:
- Around line 41-54: Wrap the network and JSON decode failures during token
exchange so they raise OAuthTokenExchangeError instead of bubbling out: catch
httpx.RequestError (including timeouts) around the httpx.post call that assigns
resp in oauth_utils (resp = httpx.post(...)) and re-raise as
OAuthTokenExchangeError(502, "<short detail including exception message>");
likewise wrap resp.json() (token_data = resp.json()) in a try/except catching
json.JSONDecodeError (or ValueError) and raise OAuthTokenExchangeError(502,
"Invalid JSON response: <exception message>") if decoding fails; keep the
existing checks for resp.status_code and missing "access_token" and include the
original exception messages in the error details for debugging.
In `@holmes/core/supabase_dal.py`:
- Around line 975-989: The get_oauth_token method drops the user filter when
user_id is None, causing it to return other users' tokens because
upsert_oauth_token stores unscoped rows with user_id=""; fix get_oauth_token to
always include a user_id equality filter: when user_id is provided use
.eq("user_id", user_id), otherwise use .eq("user_id", "") so only unscoped
tokens are returned. Update the query-building logic in get_oauth_token
(referencing OAUTH_TOKENS_TABLE and get_oauth_token) to apply that explicit
equality instead of omitting the filter.
- Around line 1025-1035: delete_oauth_token currently treats user_id=None as “no
filter” and will delete all users' tokens for the provider; to match
get_oauth_token's normalization, normalize the user_id before building the query
(e.g., if user_id is None set user_id = ""), then always add the .eq("user_id",
user_id) filter so the delete targets the global token when user_id is omitted
and does not unscoped-delete every user's record; update the delete_oauth_token
implementation to use this normalized user_id and the query-building logic
around it.
In `@holmes/core/tools_utils/tool_executor.py`:
- Around line 146-178: with_replaced_tools currently only updates executor
registries but leaves the original Toolset.tools list unchanged, causing later
syncs to re-register placeholders; when iterating replacements, create or clone
the matching Toolset object from new.enabled_toolsets (replace the entry in
new.enabled_toolsets) and set its .tools to the replacement list (apply the same
icon_url fallback as you do when populating new.tools_by_name), and also ensure
new._tool_to_toolset entries point to that cloned/updated Toolset so the
executor maps and the Toolset.tools stay in sync.
In `@holmes/plugins/toolsets/mcp/oauth_token_manager.py`:
- Around line 135-145: The DB-loaded token code reuses the original expires_in
instead of the DB's absolute token_expiry; change the logic in the block that
calls _load_from_db to compute remaining_ttl = int(db_token["token_expiry"] -
now) (use time.time() or datetime.utcnow() as appropriate), clamp it to a
non-negative value (e.g., max(0, remaining_ttl)), pass that remaining_ttl to
self._cache.set as expires_in and to self._schedule_background_refresh (instead
of db_token.get("expires_in", 300)), and keep refresh_token/refresh_expires_in
unchanged; if remaining_ttl is zero consider scheduling an immediate refresh via
the same _schedule_background_refresh call.
In `@holmes/plugins/toolsets/mcp/oauth_token_store.py`:
- Around line 96-124: The on-disk token file can be created with permissive
umask; update the token store so the file at self._path is created/chmod'ed with
0o600 to prevent group/world reads — e.g. in __init__ or in set() before/after
writing: ensure directory exists as now, then when creating/writing (in set)
either create via os.open(self._path, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600)
or call os.chmod(self._path, 0o600) immediately after json.dump; apply the same
secure-perm logic when initializing an empty file in __init__ (use self._path,
_load, and _enabled to locate behavior) so mcp_tokens.json is always only
user-readable/writable.
In `@holmes/plugins/toolsets/mcp/toolset_mcp.py`:
- Around line 600-617: _inject_oauth_token is using
_token_manager.get_access_token without the disk-key that CLI tokens are
persisted under, so persisted tokens (stored under str(toolset._mcp_config.url))
are missed; update the call in _inject_oauth_token (involving RemoteMCPToolset
and MCPConfig) to pass the disk key derived from the MCP URL (e.g.,
str(toolset._mcp_config.url)) to _token_manager.get_access_token instead of
relying on OAuthTokenManager._default_disk_key(), so the persisted CLI token is
found when the in-memory cache is cold.
- Around line 845-858: with_replaced_tools is mutating shared RemoteMCPToolset
and tool_executor state (self.toolset.tools and
tool_executor.tools_by_name/_tool_to_toolset), leaking one user's discovered
tools to others; instead, create a new RemoteMCPToolset (or shallow-copy the
toolset object) and assign its .tools to real_tools locally, and replace the
executor mappings by copying the dicts (e.g. new_tools_by_name =
dict(tool_executor.tools_by_name) and new_tool_to_toolset =
dict(tool_executor._tool_to_toolset)) then update those copies with the new tool
entries for self.name and attach them only to the per-request executor instance
returned by with_replaced_tools; reference with_replaced_tools,
RemoteMCPToolset/self.toolset, tool_executor.tools_by_name,
tool_executor._tool_to_toolset, self.name, and real_tools when making these
non-mutating replacements.
In `@holmes/utils/holmes_sync_toolsets.py`:
- Around line 96-103: The code is leaking an internal DCR URL by serializing
MCPOAuthConfig.registration_endpoint into res["oauth"]; remove
registration_endpoint from the user-facing payload (the dict assigned to
res["oauth"]) so that oauth_config.registration_endpoint is not included in
DB/API/UI toolset metadata, but continue to keep
MCPOAuthConfig.registration_endpoint available for internal use where needed (do
not change its visibility on the model). Update the block that builds
res["oauth"] (the code assigning authorization_url, token_url, client_id,
scopes) to omit registration_endpoint and add a unit/integration test asserting
that registration_endpoint is not present in the serialized toolset metadata.
- Around line 50-53: The code calls get_config_meta_for_toolset(toolset) before
possibly populating toolset.installation_instructions via
get_config_schema_for_toolset(toolset), so meta is computed from an incomplete
toolset (causing RemoteMCPToolset.oauth_config to be lost); fix by deferring or
recomputing meta after ensuring installation_instructions is set — move the
get_config_meta_for_toolset(toolset) call to after the block that fills
toolset.installation_instructions (or call it again after
get_config_schema_for_toolset) so meta reflects the generated schema and
oauth_config is persisted.
In `@server.py`:
- Around line 307-323: process_oauth_callback is storing tokens without the
per-user scope, causing authenticated users not to see their tokens later;
obtain the current request user id from the request context
(request_context["user_id"]) before calling _get_toolset_oauth_config or after,
and pass that user id into token_manager.store_token so the token is stored
under the same user-scoped key used by /api/chat; update the call to
token_manager.store_token(oauth, token_data, user_id) or the equivalent
parameter your OAuthTokenManager API expects to ensure user-scoped storage.
In `@tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/keycloak.yaml`:
- Around line 7-109: The manifest seeds sensitive credentials inline in
mcp-oauth-realm.json (client "mcp-server" secret "mcp-server-secret", user
"test-user" password "test-password", and bootstrap admin password referenced in
the Deployment env) — move these values into a Kubernetes Secret and update
references: create a Secret containing keys for mcp-server-secret,
test-user-password, and bootstrap-admin-password; modify the realm import
payload (mcp-oauth-realm.json) to reference the client secret and user
credential values from that Secret at runtime (or inject them into the realm
import container via envFrom/env), and update the Deployment to pull bootstrap
admin password and any other env vars from the same Secret instead of
hardcoding; ensure clientId "mcp-server", user "test-user", and the Deployment
env names are the ones you update.
In `@tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp-oauth-server.yaml`:
- Around line 351-357: The init container step (initContainers -> name:
install-deps) currently installs floating package versions; update the pip
install command in that container (the command list under install-deps) to pin
exact versions to match the repo lock: replace "mcp[cli]>=1.25.0" and the
unpinned httpx, uvicorn, pydantic entries with pinned versions
"mcp[cli]==1.25.0", "httpx==0.27.2", "uvicorn==0.40.0", and "pydantic==2.12.5"
so the eval fixture uses the same resolved dependencies as the repository.
In `@tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/test_case.yaml`:
- Around line 113-116: The test hook hardcodes the token path to
~/.holmes/auth/mcp_tokens.json which breaks when HOLMES_CONFIGPATH_DIR overrides
Holmes' config dir; change the script to compute the token directory from the
HOLMES_CONFIGPATH_DIR environment variable (falling back to "$HOME/.holmes" if
unset) and write to "${CONFIG_DIR}/auth/mcp_tokens.json" instead of
"~/.holmes/..."; apply the same change to the other occurrences mentioned (lines
around the cleanup at 138-140) so the DiskTokenStore and test hooks operate on
the same config directory.
- Around line 1-9: The test currently hardcodes the verification code
"CF-EVAL-4x7k9m" in expected_output; instead generate a unique verification code
in the before_test setup and reference that generated value in expected_output.
Update the test fixture to create a seeded/unique verification string during
before_test (e.g., via a helper that returns a code) and ensure the
expected_output asserts against that runtime value rather than the literal
"CF-EVAL-4x7k9m"; keep the worker name checkout-api-handler and the log error
assertion (ERR-9872-TIMEOUT) intact so the LLM searches the listing for the
generated code and the checkout-api-handler logs.
---
Outside diff comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 250-306: The code reads decision.encrypted_token (which no longer
exists) and logs it, causing AttributeError; change logging and token-extraction
to use the current payload location (decision.decision or entries in
decision.save_prefixes with "__oauth_token__:"), avoid accessing
encrypted_token, and extract the OAuth payload before any retry/invocation so
the exchanged token is available; specifically update the handling in the loop
over pending_tool_calls (ToolCallWithDecision, tool_call, decision) to (1) stop
referencing decision.encrypted_token in the logging and extraction, (2) check
decision.decision first for an OAuth payload and fallback to scanning
decision.save_prefixes for "__oauth_token__:", remove that prefix when used,
call exchange_code_for_token(tool_call.id, payload, request_context) immediately
before invoking the tool retry path (so the token is present for
_invoke_llm_tool_call), and audit any other references to encrypted_token in
this function (including the other occurrence noted) to apply the same fix.
---
Nitpick comments:
In `@tests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp_oauth_server.py`:
- Around line 164-169: KeycloakTokenVerifier.__init__ currently has untyped
parameters and add_wellknown_routes() contains function-local Starlette imports;
update the constructor signature for KeycloakTokenVerifier to annotate all
parameters as str (e.g., introspection_endpoint: str, server_url: str,
client_id: str, client_secret: str) and ensure any other constructors in this
file follow the same typing (also apply to the other constructor around the
indicated area). Move all Starlette imports used in add_wellknown_routes()
(e.g., Starlette, Route, JSONResponse, PlainTextResponse or whatever is imported
there) to the module top-level instead of importing inside the function, keeping
their original names so callers in add_wellknown_routes() continue to work.
Ensure imports remain at top of file and that type hints are added consistently
across similar fixtures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 59ce5ef8-07f5-4c8f-bc47-aefdf6ba47a8
📒 Files selected for processing (22)
conftest.pydocs/data-sources/remote-mcp-servers.mdholmes/config.pyholmes/core/models.pyholmes/core/oauth_utils.pyholmes/core/supabase_dal.pyholmes/core/tool_calling_llm.pyholmes/core/tools.pyholmes/core/tools_utils/tool_executor.pyholmes/plugins/toolsets/mcp/oauth_token_manager.pyholmes/plugins/toolsets/mcp/oauth_token_store.pyholmes/plugins/toolsets/mcp/toolset_mcp.pyholmes/utils/holmes_sync_toolsets.pypyproject.tomlserver.pytests/llm/fixtures/test_ask_holmes/254_mcp_oauth/keycloak-realm.jsontests/llm/fixtures/test_ask_holmes/254_mcp_oauth/keycloak.yamltests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp-oauth-server.yamltests/llm/fixtures/test_ask_holmes/254_mcp_oauth/mcp_oauth_server.pytests/llm/fixtures/test_ask_holmes/254_mcp_oauth/test_case.yamltests/llm/fixtures/test_ask_holmes/254_mcp_oauth/toolsets.yamltests/test_mcp_oauth.py
…oken/save_prefixes Replace the old approach of passing OAuth data via encrypted_token field or __oauth_token__: prefix in save_prefixes with the new structured decision field on ToolApprovalDecision. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
holmes/core/tool_calling_llm.py (1)
150-152: Move theserverimport out of the helper.This lazy import makes the dependency harder to follow and breaks the repo's Python import rule. If this was added to dodge a cycle, extract
process_oauth_callbackinto a dependency-neutral module instead of importing it inside_try_process_oauth_decision().As per coding guidelines, ALWAYS place Python imports at the top of the file, not inside functions or methods.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/tool_calling_llm.py` around lines 150 - 152, The import of process_oauth_callback from server is performed lazily inside _try_process_oauth_decision(), violating the top-level import rule; remove the in-function import and either (A) move "from server import process_oauth_callback" to the top of holmes/core/tool_calling_llm.py, or (B) if that creates an import cycle, extract process_oauth_callback into a new dependency-neutral module (e.g., holmes/core/oauth_utils) and import it at the top, then update _try_process_oauth_decision() to call process_oauth_callback without performing any local imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 1095-1102: The current refresh only compares lengths, which misses
changes in tool identities/schemas; modify the refresh logic around
self._get_tools() and the local variable tools so it compares the actual tool
definitions (e.g., sets of tool names and/or signatures/schema hashes) rather
than len(new_tools) != len(tools). Call self._get_tools() into new_tools,
compute a deterministic representation for each tool (name plus
signature/schema/version or a hash), compare that set/list to the representation
of tools, and if they differ log the change and replace tools = new_tools so the
LLM sees updated tool names/schemas.
- Around line 293-297: The OAuth exchange result from
_try_process_oauth_decision must be checked and, on failure, the approved tool
call must be aborted; change the code inside the if tool_decision and
tool_decision.approved block to capture the return value of
_try_process_oauth_decision(tool_decision.decision), treat a falsy/failed return
(or an exception) as an OAuth failure, log an error, and skip/invalidate the
approved tool path so the approved tool is not invoked; if needed, update
_try_process_oauth_decision to return a boolean success flag or propagate an
exception so the caller can decide to abort the tool execution.
---
Nitpick comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 150-152: The import of process_oauth_callback from server is
performed lazily inside _try_process_oauth_decision(), violating the top-level
import rule; remove the in-function import and either (A) move "from server
import process_oauth_callback" to the top of holmes/core/tool_calling_llm.py, or
(B) if that creates an import cycle, extract process_oauth_callback into a new
dependency-neutral module (e.g., holmes/core/oauth_utils) and import it at the
top, then update _try_process_oauth_decision() to call process_oauth_callback
without performing any local imports.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0e35b03d-408c-43a6-95a0-65a20f3eb0e2
📒 Files selected for processing (1)
holmes/core/tool_calling_llm.py
Signed-off-by: avi@robusta.dev <avi@robusta.dev>
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (6)
holmes/core/oauth_utils.py (2)
104-126:⚠️ Potential issue | 🟠 MajorStore callback tokens with the same request scope used for lookup.
store_token()is called without anyrequest_context, so the callback writes the token under the unscoped cache/DB key (__no_user__/user_id=None). Later/api/chatlookups use user-scoped keys and DB queries, so the user who just authenticated will not see this token. Thread request context or user identity through this helper and pass it tostore_token().🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/oauth_utils.py` around lines 104 - 126, process_oauth_callback currently calls mgr.store_token(oauth, token_data) without the request scope, which writes tokens to the unscoped cache/DB key; instead, thread the same request user/context used in lookups into the store call: retrieve the request context/user identity from the OAuthCallbackRequest (or from get_toolset_oauth_config if it returns a context), and pass it as the request_context (or appropriate parameter name expected by mgr.store_token) so tokens are stored under the user-scoped key; update the call site in process_oauth_callback to pass that context to mgr.store_token and ensure get_toolset_oauth_config and/or token_manager usage exposes the needed context if not already available.
43-56:⚠️ Potential issue | 🟠 MajorNormalize transport and JSON failures into
OAuthTokenExchangeError.Timeouts, DNS errors, and invalid JSON currently escape as generic exceptions, so
/api/oauth/callbackreturns 500 instead of the intended 502 path for upstream IdP failures.Suggested fix
- resp = httpx.post( - token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=30, - ) + try: + resp = httpx.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + except httpx.RequestError as err: + raise OAuthTokenExchangeError(502, f"Token endpoint request failed: {err}") from err @@ - token_data = resp.json() + try: + token_data = resp.json() + except ValueError as err: + raise OAuthTokenExchangeError(502, f"Invalid JSON response: {err}") from err🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/oauth_utils.py` around lines 43 - 56, Wrap the external HTTP call and JSON parsing around the httpx.post and resp.json() (the block creating resp from token_url and token_data = resp.json()) in a try/except that catches httpx.RequestError (and related httpx exceptions like TimeoutException) and JSON parsing errors (ValueError/JSONDecodeError) and re-raise them as OAuthTokenExchangeError with a 502 status and the original error message/details; preserve the existing behavior for non-200 responses and missing "access_token" but ensure all transport/JSON failures produce OAuthTokenExchangeError so the /api/oauth/callback path treats IdP failures as upstream errors.holmes/plugins/toolsets/mcp/toolset_mcp.py (2)
571-582:⚠️ Potential issue | 🟠 MajorPass the MCP URL disk key when resolving persisted CLI tokens.
CLI tokens are stored under
str(toolset._mcp_config.url), but this lookup falls back to the default auth-url-based disk key. After a cold start,_inject_oauth_token()misses the persisted token and sends unauthenticated requests even though the CLI login already succeeded.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/toolset_mcp.py` around lines 571 - 582, _inject_oauth_token currently calls _token_manager.get_access_token without supplying the MCP-specific disk key, so persisted CLI tokens saved under str(toolset._mcp_config.url) are missed; update the call to _token_manager.get_access_token(oauth_config, request_context, disk_key=str(toolset._mcp_config.url)) (or the equivalent parameter name used by _token_manager.get_access_token) so the token lookup uses the toolset._mcp_config.url as the auth disk key and returns the persisted token after cold start.
813-826:⚠️ Potential issue | 🟠 MajorDon’t mutate the shared toolset/executor during OAuth connect.
This updates
self.toolset.toolsand the live executor registries in place. Those objects are shared across requests, so one user’s discovered tool list can leak into other sessions. Build a per-request replacement toolset/executor instead of mutating the shared ones.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/toolset_mcp.py` around lines 813 - 826, The code mutates shared state (self.toolset.tools and tool_executor registries) during OAuth connect which can leak tools across requests; instead create per-request replacements: construct a new Toolset instance or shallow-copy of self.toolset and set its tools to real_tools (do not assign to self.toolset.tools), and create or clone a per-request tool_executor (copying necessary attributes but with fresh tools_by_name and _tool_to_toolset dicts), then register each tool into those per-request dicts mapping tool.name -> tool and tool.name -> new_toolset (use symbols self.toolset, real_tools, self.name, tool_executor, tools_by_name, _tool_to_toolset to locate spots to change). Finally ensure the rest of the request uses these new per-request toolset and executor instead of mutating the shared ones.holmes/plugins/toolsets/mcp/oauth_token_manager.py (1)
127-138:⚠️ Potential issue | 🟠 MajorUse the DB row’s absolute expiry when rehydrating the cache.
This still re-caches a DB-loaded token with its original
expires_in, not the remaining TTL. A token written 25 minutes ago withexpires_in=1800gets another fresh 30 minutes here, so Holmes can return an already-expired access token until the next refresh path runs._load_from_db()needs to surface the stored absolute expiry and this branch should cache/schedule from the remaining lifetime instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/oauth_token_manager.py` around lines 127 - 138, The DB-loaded token branch currently reuses the stored expires_in rather than the remaining TTL; update _load_from_db to return the token's absolute expiry (e.g., "expires_at" timestamp) or otherwise expose the stored expiry, then in the branch that handles db_token compute remaining_ttl = max(0, expires_at - now); if remaining_ttl <= 0 treat the token as expired (don't cache or trigger refresh) otherwise call self._cache.set(..., expires_in=remaining_ttl, refresh_token=..., refresh_expires_in=...) and call self._schedule_background_refresh(cache_key, oauth_config, remaining_ttl, user_id). Ensure you reference db_token, _load_from_db, _cache.set and _schedule_background_refresh when making these changes.holmes/core/tools_utils/tool_executor.py (1)
152-184:⚠️ Potential issue | 🟠 MajorKeep the cloned
Toolsetin sync with the registry replacements.
with_replaced_tools()only rewritestools_by_nameand_tool_to_toolset.new.enabled_toolsetsstill holds the original placeholdertools, so any later_sync_dynamic_tools()miss can re-register the placeholder you just removed. Clone/update the matchingToolsetand replace its.toolslist as part of this path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/tools_utils/tool_executor.py` around lines 152 - 184, with_replaced_tools currently updates tools_by_name and _tool_to_toolset but leaves the Toolset objects in new.enabled_toolsets unchanged, so later _sync_dynamic_tools can re-register the original placeholders; fix by finding the matching Toolset in new.enabled_toolsets (same loop that finds `toolset`) and replace or update its Toolset.tools list to the replacement `new_tools` (applying the same icon_url fallback logic used when inserting into tools_by_name), ensuring new._tool_to_toolset and new.tools_by_name remain consistent with the updated Toolset so _sync_dynamic_tools won't restore placeholders.
🧹 Nitpick comments (1)
tests/test_mcp_oauth.py (1)
1426-1459: Cover the placeholder-resurrection case in these replacement tests.These assertions only verify
tools_by_name. The current bug is thatwith_replaced_tools()can leaveToolset.toolsstale, so a later lookup miss triggers_sync_dynamic_tools()and re-registers the placeholder. Please also assert that the cloned toolset’s.toolslist was replaced and that the old placeholder stays gone after a miss.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/test_mcp_oauth.py` around lines 1426 - 1459, The tests must cover the placeholder-resurrection bug by asserting that with_replaced_tools actually replaces the Toolset.tools list (not just tools_by_name) and that a subsequent lookup miss does not re-register the placeholder; update the two tests to (1) inspect the returned augmented toolset object from executor.with_replaced_tools("my-mcp") and assert augmented_toolset.tools contains the new real tool objects and does not contain the original placeholder, and (2) force a lookup miss that triggers ToolExecutor._sync_dynamic_tools (e.g., call whatever lookup method triggers a miss or remove the tool from tools_by_name) and then assert the placeholder is still absent from both augmented.tools and augmented.tools_by_name; reference the methods/classes ToolExecutor.with_replaced_tools, Toolset.tools, ToolExecutor._sync_dynamic_tools, and tools_by_name when making the assertions so the test proves the placeholder isn’t resurrected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@holmes/plugins/toolsets/mcp/oauth_token_manager.py`:
- Around line 199-203: The info logs in OAuthTokenManager that currently print
raw cache_key (e.g., the logger.info call after
self._schedule_background_refresh and other logger lines referencing
cache_key/user_id) leak request-scoped identifiers; update those log statements
to emit a redacted or hashed representation of cache_key/user_id (compute a
stable hash or redact sensitive parts before logging) and apply the same change
to every other occurrence in this class where cache_key or user_id is logged so
no raw identifiers appear in application logs; keep the log text and variables
otherwise unchanged (e.g., still log expires_in and has_refresh) while replacing
cache_key/user_id with the redacted/hash value.
- Around line 480-484: The existing logic sets expiry from refresh_expires_in
before expires_in which records the refresh-token lifetime as the record's
token_expiry; change the assignment so token_data["expires_in"] (the
access-token lifetime) is used to compute expiry first and only fall back to
token_data["refresh_expires_in"] when expires_in is missing, and if you need to
persist refresh lifetime store it separately (e.g., as refresh_expiry or
refresh_token_expiry) rather than overriding token_expiry; update the code
around expiry/token_data handling in oauth_token_manager.py accordingly.
In `@holmes/plugins/toolsets/mcp/toolset_mcp.py`:
- Around line 372-389: The callback handler currently accepts any returned code
without verifying the OAuth "state"; update CallbackHandler.do_GET to validate
the returned params.get("state", [None])[0] against the original generated state
(e.g., the outer-scope variable named state or expected_state) and only set
result["code"] when they match; if missing or mismatched, set
result["error"]="invalid_state" (and error_description accordingly), send a 400
response and do not proceed with exchanging the code; ensure
callback_event.set() is still called so the flow unblocks.
---
Duplicate comments:
In `@holmes/core/oauth_utils.py`:
- Around line 104-126: process_oauth_callback currently calls
mgr.store_token(oauth, token_data) without the request scope, which writes
tokens to the unscoped cache/DB key; instead, thread the same request
user/context used in lookups into the store call: retrieve the request
context/user identity from the OAuthCallbackRequest (or from
get_toolset_oauth_config if it returns a context), and pass it as the
request_context (or appropriate parameter name expected by mgr.store_token) so
tokens are stored under the user-scoped key; update the call site in
process_oauth_callback to pass that context to mgr.store_token and ensure
get_toolset_oauth_config and/or token_manager usage exposes the needed context
if not already available.
- Around line 43-56: Wrap the external HTTP call and JSON parsing around the
httpx.post and resp.json() (the block creating resp from token_url and
token_data = resp.json()) in a try/except that catches httpx.RequestError (and
related httpx exceptions like TimeoutException) and JSON parsing errors
(ValueError/JSONDecodeError) and re-raise them as OAuthTokenExchangeError with a
502 status and the original error message/details; preserve the existing
behavior for non-200 responses and missing "access_token" but ensure all
transport/JSON failures produce OAuthTokenExchangeError so the
/api/oauth/callback path treats IdP failures as upstream errors.
In `@holmes/core/tools_utils/tool_executor.py`:
- Around line 152-184: with_replaced_tools currently updates tools_by_name and
_tool_to_toolset but leaves the Toolset objects in new.enabled_toolsets
unchanged, so later _sync_dynamic_tools can re-register the original
placeholders; fix by finding the matching Toolset in new.enabled_toolsets (same
loop that finds `toolset`) and replace or update its Toolset.tools list to the
replacement `new_tools` (applying the same icon_url fallback logic used when
inserting into tools_by_name), ensuring new._tool_to_toolset and
new.tools_by_name remain consistent with the updated Toolset so
_sync_dynamic_tools won't restore placeholders.
In `@holmes/plugins/toolsets/mcp/oauth_token_manager.py`:
- Around line 127-138: The DB-loaded token branch currently reuses the stored
expires_in rather than the remaining TTL; update _load_from_db to return the
token's absolute expiry (e.g., "expires_at" timestamp) or otherwise expose the
stored expiry, then in the branch that handles db_token compute remaining_ttl =
max(0, expires_at - now); if remaining_ttl <= 0 treat the token as expired
(don't cache or trigger refresh) otherwise call self._cache.set(...,
expires_in=remaining_ttl, refresh_token=..., refresh_expires_in=...) and call
self._schedule_background_refresh(cache_key, oauth_config, remaining_ttl,
user_id). Ensure you reference db_token, _load_from_db, _cache.set and
_schedule_background_refresh when making these changes.
In `@holmes/plugins/toolsets/mcp/toolset_mcp.py`:
- Around line 571-582: _inject_oauth_token currently calls
_token_manager.get_access_token without supplying the MCP-specific disk key, so
persisted CLI tokens saved under str(toolset._mcp_config.url) are missed; update
the call to _token_manager.get_access_token(oauth_config, request_context,
disk_key=str(toolset._mcp_config.url)) (or the equivalent parameter name used by
_token_manager.get_access_token) so the token lookup uses the
toolset._mcp_config.url as the auth disk key and returns the persisted token
after cold start.
- Around line 813-826: The code mutates shared state (self.toolset.tools and
tool_executor registries) during OAuth connect which can leak tools across
requests; instead create per-request replacements: construct a new Toolset
instance or shallow-copy of self.toolset and set its tools to real_tools (do not
assign to self.toolset.tools), and create or clone a per-request tool_executor
(copying necessary attributes but with fresh tools_by_name and _tool_to_toolset
dicts), then register each tool into those per-request dicts mapping tool.name
-> tool and tool.name -> new_toolset (use symbols self.toolset, real_tools,
self.name, tool_executor, tools_by_name, _tool_to_toolset to locate spots to
change). Finally ensure the rest of the request uses these new per-request
toolset and executor instead of mutating the shared ones.
---
Nitpick comments:
In `@tests/test_mcp_oauth.py`:
- Around line 1426-1459: The tests must cover the placeholder-resurrection bug
by asserting that with_replaced_tools actually replaces the Toolset.tools list
(not just tools_by_name) and that a subsequent lookup miss does not re-register
the placeholder; update the two tests to (1) inspect the returned augmented
toolset object from executor.with_replaced_tools("my-mcp") and assert
augmented_toolset.tools contains the new real tool objects and does not contain
the original placeholder, and (2) force a lookup miss that triggers
ToolExecutor._sync_dynamic_tools (e.g., call whatever lookup method triggers a
miss or remove the tool from tools_by_name) and then assert the placeholder is
still absent from both augmented.tools and augmented.tools_by_name; reference
the methods/classes ToolExecutor.with_replaced_tools, Toolset.tools,
ToolExecutor._sync_dynamic_tools, and tools_by_name when making the assertions
so the test proves the placeholder isn’t resurrected.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 14b86e99-9aab-45e2-ab02-3a88e8310fb6
📒 Files selected for processing (8)
holmes/core/models.pyholmes/core/oauth_utils.pyholmes/core/tool_calling_llm.pyholmes/core/tools_utils/tool_executor.pyholmes/plugins/toolsets/mcp/oauth_token_manager.pyholmes/plugins/toolsets/mcp/toolset_mcp.pyserver.pytests/test_mcp_oauth.py
🚧 Files skipped from review as they are similar to previous changes (1)
- holmes/core/tool_calling_llm.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (6)
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)
569-586:⚠️ Potential issue | 🟠 MajorPass the MCP URL disk key when resolving persisted CLI tokens here.
CLI auth stores tokens under
str(self.toolset._mcp_config.url)in Lines 736-739, but_inject_oauth_token()falls back to the manager default key. Once the in-memory cache is cold, persisted CLI tokens will be missed and authenticated requests regress to 401s.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/toolset_mcp.py` around lines 569 - 586, _inject_oauth_token currently calls _token_manager.get_access_token(oauth_config, request_context) which uses the manager default disk key and therefore misses CLI-persisted tokens stored under the MCP URL; update the call in _inject_oauth_token to pass the MCP URL disk key (e.g. use str(toolset._mcp_config.url)) so get_access_token resolves persisted CLI tokens for this toolset, ensuring you still reference toolset._mcp_config and MCPConfig to locate the url when building that disk key.
372-389:⚠️ Potential issue | 🔴 CriticalValidate the OAuth
statebefore accepting the callback.The CLI flow generates
state, butCallbackHandler.do_GET()accepts any returnedcodewithout comparing it. That drops the CSRF/session-binding check for this OAuth flow.Suggested direction
def do_GET(self): parsed = urlparse(self.path) params = parse_qs(parsed.query) - if "code" in params: + returned_state = params.get("state", [None])[0] + if returned_state != state: + result["error"] = "invalid_state" + result["error_description"] = "OAuth state mismatch" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"<h1>Error: invalid_state</h1>") + elif "code" in params: result["code"] = params["code"][0] self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(b"<h1>Authenticated! You can close this tab.</h1>") else: result["error"] = params.get("error", ["unknown"])[0] result["error_description"] = params.get("error_description", [""])[0] self.send_response(400) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(f"<h1>Error: {result['error']}</h1>".encode()) callback_event.set()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/toolset_mcp.py` around lines 372 - 389, CallbackHandler.do_GET currently accepts any returned code without verifying the OAuth `state`; update do_GET to read the incoming "state" query param and compare it to the expected state value generated by the CLI (the outer-scope/state variable used when starting the server). If the states match, proceed to set result["code"] and respond 200 as before; if they do not match or "state" is missing, set result["error"]="invalid_state" (and optionally result["error_description"]) and respond 400 without storing result["code"]; in all cases ensure callback_event.set() is still called. Locate the CallbackHandler class and do_GET method to add the state check and use the existing result and callback_event symbols.
811-824:⚠️ Potential issue | 🟠 MajorAvoid mutating the shared toolset/executor during OAuth connect.
This branch rewrites
self.toolset.toolsandtool_executor.tools_by_name/_tool_to_toolsetin place. Because those objects are shared, one user's discovered tool list can leak into other requests.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/plugins/toolsets/mcp/toolset_mcp.py` around lines 811 - 824, The code mutates shared state (self.toolset.tools and tool_executor.tools_by_name/_tool_to_toolset) which can leak tools between requests; instead create request-scoped copies and register those without altering globals: clone the tools list (e.g., new_tools = list(real_tools)) and assign it to a request-local toolset/variable rather than self.toolset.tools, and when registering with the executor avoid mutating its global maps—either build a temporary mapping for this request or clone the executor's maps, update the clones with entries for each tool.name (using self.name and real_tools to locate items), and use those clones for the current operation (or restore originals after use) so shared objects are never overwritten in place.holmes/core/tool_calling_llm.py (2)
1092-1099:⚠️ Potential issue | 🟠 MajorRefresh tools on definition changes, not just count changes.
OAuth discovery can swap placeholder tools for real tools without changing the total count. With
len(new_tools) != len(tools), the next LLM turn can keep stale tool names/schema.Suggested direction
if tools is not None: new_tools = self._get_tools() - if len(new_tools) != len(tools): + if json.dumps(new_tools, sort_keys=True) != json.dumps( + tools, sort_keys=True + ): logging.warning( f"Tool list changed - refreshing ({len(tools)} -> {len(new_tools)} tools)" ) tools = new_tools🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/tool_calling_llm.py` around lines 1092 - 1099, The refresh logic in the tool fetching block only checks len(new_tools) != len(tools) so replacements (e.g., OAuth discovery swapping placeholders for real tools) with the same count won't trigger a refresh; update the check in the method containing self._get_tools() to do a content-level comparison between tools and new_tools (for example compare tool IDs/names and signatures/metadata or perform a shallow deep-equality on each tool object) instead of only comparing lengths, and if any difference is found (name/schema/description/params changed) assign tools = new_tools and emit the warning; reference the existing variables tools and new_tools and the helper self._get_tools() when making the change.
143-157:⚠️ Potential issue | 🟠 MajorAbort the approved tool call when the OAuth exchange fails.
_try_process_oauth_decision()only logs failures, and_execute_tool_decisions()immediately invokes the approved tool anyway. That turns a clear auth failure into a second downstream tool call against an unauthenticated session.Suggested direction
-def _try_process_oauth_decision( +def _try_process_oauth_decision( tool_call_id: str, decision_data: Dict[str, Any], request_context: Optional[Dict[str, Any]], -) -> None: +) -> Optional[str]: @@ try: payload_json = json.dumps(decision_data) exchange_code_for_token(tool_call_id, payload_json, request_context) + return None except Exception as e: logging.error(f"Failed to process OAuth decision: {e}", exc_info=True) + return str(e) @@ if tool_decision.decision: - _try_process_oauth_decision(tool_call.id, tool_decision.decision, request_context) + oauth_error = _try_process_oauth_decision( + tool_call.id, tool_decision.decision, request_context + ) + if oauth_error: + tool_result = ToolCallResult( + tool_call_id=tool_call.id, + tool_name=tool_call.function.name, + description=tool_call.function.name, + result=StructuredToolResult( + status=StructuredToolResultStatus.ERROR, + error=oauth_error, + ), + ) + continueAlso applies to: 290-304
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/tool_calling_llm.py` around lines 143 - 157, The OAuth exchange failure in _try_process_oauth_decision currently only logs the error so execution continues; change it to abort the approved tool call by propagating the failure: catch exceptions from exchange_code_for_token, log with exc_info, then either raise a specific exception (e.g., OAuthExchangeError) or re-raise the caught exception so callers like _execute_tool_decisions see the failure and can stop invoking the approved tool; ensure the chosen exception type is imported/defined and update any caller logic to handle/propagate that exception to abort the tool execution.holmes/core/oauth_utils.py (1)
43-56:⚠️ Potential issue | 🟠 MajorWrap transport and JSON failures in
OAuthTokenExchangeError.
httpx.post()timeouts/request errors andresp.json()decode failures currently bubble out as generic exceptions, so callers miss the intended token-exchange error path and return a 500 instead of a handled upstream failure.Suggested direction
- resp = httpx.post( - token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=30, - ) + try: + resp = httpx.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + except httpx.RequestError as e: + raise OAuthTokenExchangeError(502, f"Token endpoint request failed: {e}") from e @@ - token_data = resp.json() + try: + token_data = resp.json() + except ValueError as e: + raise OAuthTokenExchangeError(502, f"Invalid JSON response: {e}") from e🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@holmes/core/oauth_utils.py` around lines 43 - 56, The code calls httpx.post(...) and resp.json() directly which lets httpx.RequestError/Timeout and JSON decode exceptions escape; wrap the httpx.post call in a try/except catching httpx.RequestError (and httpx.TimeoutException if desired) and re-raise OAuthTokenExchangeError with a useful message and a non-200 pseudo-status (e.g. 0 or the underlying exception details), and likewise wrap resp.json() in try/except catching JSONDecodeError/ValueError to raise OAuthTokenExchangeError with the response text/snippet and parsing error; update the logic around resp, resp.status_code, and token_data so all transport and JSON failures are translated into OAuthTokenExchangeError (referencing httpx.post, resp, resp.json(), and OAuthTokenExchangeError).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@holmes/plugins/toolsets/mcp/toolset_mcp.py`:
- Around line 1376-1385: The code currently only logs when both
oauth_config.client_id and registration_endpoint are missing, but still reports
discovery success; change the branch in the if not oauth_config.client_id block
so that when registration_endpoint is also falsy you log an error (including
self.name and that both client_id and registration_endpoint are missing) and
fail discovery immediately (e.g., raise a specific exception or return a failure
result) instead of continuing; ensure the code path that emits the approval
payload honors this failure so no successful discovery/approval is reported when
neither oauth_config.client_id nor registration_endpoint is available.
- Line 391: The method log_message currently uses a parameter named format which
shadows the built-in and lacks type hints; rename that parameter to
format_string (or similar) and add type annotations to the signature (e.g.,
format_string: str, *args: Any) and a return type of None; also ensure Any is
imported from typing at top of the module if not already. Update any internal
references to the old parameter name inside log_message to the new name and run
linters to verify Ruff A002 and typing rules are satisfied.
- Around line 1247-1255: The health-check fallback currently uses a
non-idempotent POST to probe reachability; update
_check_oauth_server_reachable() to use a read-only method (HEAD or GET) instead
of httpx.post for the root probe so startup checks cannot cause side-effects.
Replace the httpx.post(url, ...) call with a httpx.head(url, timeout=...,
verify=self._mcp_config.verify_ssl) (or httpx.get if HEAD is unsupported by some
endpoints), preserving the same timeout and verify parameters and handling
status_code checks the same way.
---
Duplicate comments:
In `@holmes/core/oauth_utils.py`:
- Around line 43-56: The code calls httpx.post(...) and resp.json() directly
which lets httpx.RequestError/Timeout and JSON decode exceptions escape; wrap
the httpx.post call in a try/except catching httpx.RequestError (and
httpx.TimeoutException if desired) and re-raise OAuthTokenExchangeError with a
useful message and a non-200 pseudo-status (e.g. 0 or the underlying exception
details), and likewise wrap resp.json() in try/except catching
JSONDecodeError/ValueError to raise OAuthTokenExchangeError with the response
text/snippet and parsing error; update the logic around resp, resp.status_code,
and token_data so all transport and JSON failures are translated into
OAuthTokenExchangeError (referencing httpx.post, resp, resp.json(), and
OAuthTokenExchangeError).
In `@holmes/core/tool_calling_llm.py`:
- Around line 1092-1099: The refresh logic in the tool fetching block only
checks len(new_tools) != len(tools) so replacements (e.g., OAuth discovery
swapping placeholders for real tools) with the same count won't trigger a
refresh; update the check in the method containing self._get_tools() to do a
content-level comparison between tools and new_tools (for example compare tool
IDs/names and signatures/metadata or perform a shallow deep-equality on each
tool object) instead of only comparing lengths, and if any difference is found
(name/schema/description/params changed) assign tools = new_tools and emit the
warning; reference the existing variables tools and new_tools and the helper
self._get_tools() when making the change.
- Around line 143-157: The OAuth exchange failure in _try_process_oauth_decision
currently only logs the error so execution continues; change it to abort the
approved tool call by propagating the failure: catch exceptions from
exchange_code_for_token, log with exc_info, then either raise a specific
exception (e.g., OAuthExchangeError) or re-raise the caught exception so callers
like _execute_tool_decisions see the failure and can stop invoking the approved
tool; ensure the chosen exception type is imported/defined and update any caller
logic to handle/propagate that exception to abort the tool execution.
In `@holmes/plugins/toolsets/mcp/toolset_mcp.py`:
- Around line 569-586: _inject_oauth_token currently calls
_token_manager.get_access_token(oauth_config, request_context) which uses the
manager default disk key and therefore misses CLI-persisted tokens stored under
the MCP URL; update the call in _inject_oauth_token to pass the MCP URL disk key
(e.g. use str(toolset._mcp_config.url)) so get_access_token resolves persisted
CLI tokens for this toolset, ensuring you still reference toolset._mcp_config
and MCPConfig to locate the url when building that disk key.
- Around line 372-389: CallbackHandler.do_GET currently accepts any returned
code without verifying the OAuth `state`; update do_GET to read the incoming
"state" query param and compare it to the expected state value generated by the
CLI (the outer-scope/state variable used when starting the server). If the
states match, proceed to set result["code"] and respond 200 as before; if they
do not match or "state" is missing, set result["error"]="invalid_state" (and
optionally result["error_description"]) and respond 400 without storing
result["code"]; in all cases ensure callback_event.set() is still called. Locate
the CallbackHandler class and do_GET method to add the state check and use the
existing result and callback_event symbols.
- Around line 811-824: The code mutates shared state (self.toolset.tools and
tool_executor.tools_by_name/_tool_to_toolset) which can leak tools between
requests; instead create request-scoped copies and register those without
altering globals: clone the tools list (e.g., new_tools = list(real_tools)) and
assign it to a request-local toolset/variable rather than self.toolset.tools,
and when registering with the executor avoid mutating its global maps—either
build a temporary mapping for this request or clone the executor's maps, update
the clones with entries for each tool.name (using self.name and real_tools to
locate items), and use those clones for the current operation (or restore
originals after use) so shared objects are never overwritten in place.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 039ae2b4-45ce-4f7b-a626-28f3285994a3
📒 Files selected for processing (5)
holmes/core/models.pyholmes/core/oauth_utils.pyholmes/core/supabase_dal.pyholmes/core/tool_calling_llm.pyholmes/plugins/toolsets/mcp/toolset_mcp.py
🚧 Files skipped from review as they are similar to previous changes (1)
- holmes/core/supabase_dal.py
…ests Added autouse fixture to restore _store after each test, preventing MagicMock stores from leaking across test boundaries via the singleton. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…ording Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…agicMock results Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
…e cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
CLI no longer injects a fake user_id. Instead, store_user_tools uses __no_user__ as key when user_id is None, and find_tool/resolve_tools/ get_toolset fall back to __no_user__ for lookups. This keeps CLI mode working (DiskTokenStore, no user_id) while server mode (DalTokenStore) still requires a real user_id. Eliminates the cli_user spoofing vector. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
DalTokenStore is shared across clusters — another cluster may have already refreshed the token. Only DiskTokenStore (CLI) is safe to delete from on 401. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
- Add DEFAULT_CLI_USER constant in env_vars.py - Add require_user_id() method on OAuthTokenManager: returns DEFAULT_CLI_USER in CLI mode (DiskTokenStore), raises in server mode (DalTokenStore) when user_id is missing - Remove scattered "or __no_user__" fallbacks from connector and LLM - DiskTokenStore.get_all_for_preload returns DEFAULT_CLI_USER directly - Move DiskTokenStore and _get_token_manager imports to top of files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Server-mode requests without user_id now silently skip OAuth tools (user sees _connect placeholders) instead of crashing with HTTP 500. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: avi@robusta.dev <avi@robusta.dev>
Signed-off-by: avi@robusta.dev <avi@robusta.dev>
| ToolApprovalDecision, | ||
| ToolCallResult, | ||
| ) | ||
| from holmes.core.oauth_config import OAuthTokenExchangeError, _get_exchange_manager, parse_oauth_decision |
There was a problem hiding this comment.
🟡 Line 37 imports OAuthTokenExchangeError from holmes.core.oauth_config, but the name is never referenced anywhere else in the file — _try_process_oauth_decision catches generic Exception rather than this specific type. The other two imports on that line (_get_exchange_manager, parse_oauth_decision) are used, so trim the import to from holmes.core.oauth_config import _get_exchange_manager, parse_oauth_decision. Note: the sibling from json import tool on line 3 is also dead (every tool reference in the file is a local variable that shadows the import) — same file, worth cleaning up together.
Extended reasoning...
What the bug is\n\nLine 37 of holmes/core/tool_calling_llm.py reads:\n\npython\nfrom holmes.core.oauth_config import OAuthTokenExchangeError, _get_exchange_manager, parse_oauth_decision\n\n\nOf the three names imported, OAuthTokenExchangeError is never referenced. The other two are used inside _try_process_oauth_decision and _execute_tool_decisions.\n\n### Step-by-step proof\n\n1. grep -n 'OAuthTokenExchangeError' holmes/core/tool_calling_llm.py → exactly one hit, line 37 (the import itself).\n2. The nearby function _try_process_oauth_decision (line 159) is the natural candidate for catching this exception, but it uses a blanket except Exception as e: rather than the specific type:\n python\n def _try_process_oauth_decision(tool_call_id, oauth_code, request_context) -> bool:\n try:\n _get_exchange_manager().complete_exchange(tool_call_id, oauth_code, request_context)\n return True\n except Exception as e:\n logging.error(f"Failed to process OAuth decision: {e}", exc_info=True)\n return False\n \n3. There is no type hint, no isinstance check, and no raise of OAuthTokenExchangeError anywhere else in the file. The name is purely dead.\n\n### Impact\n\nZero runtime impact. Pure code-hygiene: misleads readers into thinking the file handles this specific exception type, and will trip linters configured for unused-import detection (F401). Severity is nit.\n\n### Companion issue\n\nLine 3 of the same file has from json import tool, which imports the stdlib json.tool CLI pretty-printer. Every subsequent tool identifier in the file is a local variable (e.g., line 669 tool = self.tool_executor.get_tool_by_name(...)) that shadows the import. That is also dead. An earlier CodeRabbit review already flagged it, so bundling the two unused-import fixes in the same cleanup avoids a second round-trip.\n\n### How to fix\n\ndiff\n-from json import tool\n ...\n-from holmes.core.oauth_config import OAuthTokenExchangeError, _get_exchange_manager, parse_oauth_decision\n+from holmes.core.oauth_config import _get_exchange_manager, parse_oauth_decision\n
| def require_user_id(self, request_context: Optional[Dict[str, Any]]) -> str: | ||
| """Return a user_id, using DEFAULT_CLI_USER in CLI mode or raising in server mode. | ||
|
|
||
| CLI mode (DiskTokenStore / no store): returns DEFAULT_CLI_USER. | ||
| Server mode (DalTokenStore): raises ValueError if user_id is missing. | ||
| """ | ||
| user_id = _get_user_id(request_context) | ||
| if user_id: | ||
| return user_id | ||
| if isinstance(self._store, DalTokenStore): | ||
| return None | ||
| return DEFAULT_CLI_USER |
There was a problem hiding this comment.
🟡 The return type annotation on require_user_id (oauth_token_manager.py:228) says -> str, but line 238 returns None when DalTokenStore is active and no user_id is in the request context (see commit b4f5e46). The docstring is also stale — it still says "raises ValueError if user_id is missing in Server mode" even though the code now returns None. No runtime crash today (callers either use the return as a dict key via dict.get(...), which tolerates None, or pass it into functions like store_user_tools/upsert_oauth_token that already short-circuit on falsy input), but the annotation hides the None case from mypy/pyright and misleads future readers. Update the annotation to Optional[str] and fix the docstring to match the new return-None contract.
Extended reasoning...
What the bug is
At holmes/plugins/toolsets/mcp/oauth_token_manager.py:228-239, OAuthTokenManager.require_user_id is declared:
def require_user_id(self, request_context: Optional[Dict[str, Any]]) -> str:
"""Return a user_id, using DEFAULT_CLI_USER in CLI mode or raising in server mode.
CLI mode (DiskTokenStore / no store): returns DEFAULT_CLI_USER.
Server mode (DalTokenStore): raises ValueError if user_id is missing.
"""
user_id = _get_user_id(request_context)
if user_id:
return user_id
if isinstance(self._store, DalTokenStore):
return None # <-- violates `-> str` annotation
return DEFAULT_CLI_USERCommit b4f5e46 (require_user_id returns None in server mode instead of raising) intentionally changed line 238 from raise ValueError(...) to return None, but neither the signature nor the docstring was updated. The annotation now lies about the contract, and the docstring contradicts the code.
How it manifests
Static type checkers (mypy, pyright) trust the -> str annotation and will not flag callers that assume a non-None result. Human readers of the docstring will expect a ValueError that will never arrive.
The specific code paths that use the return value
oauth_tool_connector.py:153, 193, 203—key = user_id or _get_token_manager().require_user_id(None), thenself._user_tools.get(key)/self._user_tool_to_toolset.get(key, {}).get(tool_name).dict.get(None)works fine as a lookup and simply returnsNone/no-match, so no crash.oauth_tool_connector.py:75—user_id = _get_token_manager().require_user_id(request_context)is passed toload_tools_for_user(user_id, ...)which doesuser_id[:6] if user_id else user_id(None-safe) and tostore_user_tools(user_id, ...)which usesuser_idonly as a dict key.tool_calling_llm.py:697—effective_user = _get_token_manager().require_user_id(request_context)thenstore_user_tools(effective_user, toolset_name, ...).store_user_toolsannotatesuser_id: strbut operates on it purely as a dict key, so passingNonedoes not crash.supabase_dal.upsert_oauth_tokenatsupabase_dal.py:1001-1003already short-circuits onif not user_id: logging.warning(...); return False, so any DB write path withNoneuser_id is a silent no-op rather than a crash.
Why existing code does not prevent it
There is no runtime assertion that the return is non-None, and the annotation is the only line-of-defense for type-checkers. The existing tolerant-of-None downstream code masks the annotation lie at runtime.
Impact
No runtime incident today — this is a documentation/type-safety defect. Severity is nit. But the lie will bite when a future caller adds a .startswith("user-") or similar str operation on the result, or when someone refactors store_user_tools to enforce its user_id: str annotation.
How to fix
One-line annotation fix plus docstring rewrite:
def require_user_id(self, request_context: Optional[Dict[str, Any]]) -> Optional[str]:
"""Return a user_id, or None when none can be determined.
- CLI mode (DiskTokenStore / no store): returns DEFAULT_CLI_USER as fallback.
- Server mode (DalTokenStore): returns None when no user_id is present in the
request context (callers must handle None — typically as a no-op for lookups
and an early return for writes).
"""Step-by-step proof
- Call
manager.require_user_id({})in server mode (self._storeis aDalTokenStore). - Line 234:
_get_user_id({})returnsNone. - Line 235:
if user_id:→ False. - Line 237:
isinstance(self._store, DalTokenStore)→ True. - Line 238:
return None. - Annotation
-> stris violated. Any caller that a type-checker treats as returningstrwill not be flagged on this path.
| # No token found anywhere — need to authenticate | ||
| user_id = _get_user_id(context.request_context) | ||
|
|
There was a problem hiding this comment.
🟡 The variable user_id = _get_user_id(context.request_context) on line 303 is assigned but never used anywhere else in requires_approval. The subsequent CLI branching uses context.request_context is None directly and passes context.request_context to downstream calls — user_id is dead code. Either delete the assignment or reference it in the CLI-mode log message for traceability.
Extended reasoning...
What the bug is
At holmes/plugins/toolsets/mcp/toolset_mcp.py:303, inside RemoteMCPTool.requires_approval, the line user_id = _get_user_id(context.request_context) binds a local variable that is never read in the remainder of the function. The only references to user_id in the whole file are the import on line 47 and this assignment on line 303 — grep confirms no other occurrences.
Why the assignment is dead
After line 303, the function does the following:
- Line 306:
is_cli = context.request_context is None— checks the context directly, notuser_id. - Line 317:
cli_oauth_flow(oauth_endpoints, self.toolset.name)— does not receiveuser_id. - Line 319–322:
_get_token_manager().store_token(oauth_config, token_data, context.request_context, ...)— passescontext.request_context(the manager internally re-extractsuser_idvia its own helpers), not the localuser_id. - Lines 332–336:
register_pending(tool_call_id=context.tool_call_id, code_verifier=code_verifier, oauth_config=oauth_config)— nouser_idargument. - Lines 338–348 and 350–353: the OAuth metadata dict and
ApprovalRequirementreturn value contain nouser_id.
The variable is therefore pure dead code. Python does not raise or warn on unused locals, and Ruff's F841 would catch it if enabled on this path.
Why existing code does not prevent it
There is no linter gate that flags unused locals in this file. The import of _get_user_id is valid (used at import time), so nothing in the codebase signals that the binding is orphaned.
Impact
Zero runtime impact — it's purely cosmetic. The logic is correct: the CLI vs. frontend branching uses context.request_context is None, which is the right check (DEFAULT_CLI_USER is the fallback user_id in CLI mode, so user_id would always be non-None and couldn't discriminate CLI from server anyway).
How to fix
Two reasonable options:
- Delete the assignment. Smallest diff, removes dead code.
- Use it for log traceability. The CLI-mode and OAuth-failure log lines in this function (
OAuth MCP %s: CLI mode, running browser OAuth flow,OAuth MCP %s: CLI auth successful,OAuth MCP %s: CLI OAuth flow failed) would be more useful if they includeduser_id— this is likely the original intent of the binding.
Step-by-step proof
grep -n user_id holmes/plugins/toolsets/mcp/toolset_mcp.pyreturns exactly two lines: the import on line 47 and the assignment on line 303.- Inspect the function body from line 303 onwards (lines 303–353). Search for any expression that reads
user_id. There are none — the next references to the identifieruser_idin the file do not exist. - The function returns at one of three points (lines 300, 324, 350) without ever reading
user_id. - Therefore removing line 303 produces no behavioural change.
| def process_oauth_decision( | ||
| self, | ||
| tool_call_id: str, | ||
| decision: Optional[Dict[str, Any]], | ||
| request_context: Optional[Dict[str, Any]], | ||
| toolset: Any = None, | ||
| ) -> Optional[Tuple[str, List[Tool]]]: |
There was a problem hiding this comment.
🟡 OAuthToolConnector.process_oauth_decision (lines 42-80) and its helper _try_exchange (lines 82-94) are defined but never called anywhere in the codebase. The live OAuth decision path at tool_calling_llm.py:313 uses the module-level helper _try_process_oauth_decision (line 159) which invokes _get_exchange_manager().complete_exchange() directly, bypassing this method entirely. The class docstring claims ToolCallingLLM delegates OAuth decisions to it without needing OAuth-specific imports or logic, but the actual delegation never happens — tool_calling_llm.py still imports _get_exchange_manager, parse_oauth_decision, and OAuthTokenExchangeError directly. Either wire _execute_tool_decisions to call self.tool_executor.oauth_connector.process_oauth_decision, or delete both methods.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests
Chores
manualpytest marker and CI-aware skipping of manual tests.