[OPIK-7033] Name the OAuth-authorized workspace in per-session MCP instructions#151
Merged
Merged
Conversation
…uctions Resolve the workspace name from the inbound OAuth bearer on the MCP `initialize` handshake (POST /opik/auth-oauth introspection) and inject it into the per-session InitializeResult.instructions blob, so an agent can truthfully name the workspace it operates against. Precedence: Comet-Workspace header > OAuth-introspected name > static settings > "default". - oauth_identity.resolve_workspace_name: best-effort token introspection, fails soft and never raises (broad except so httpx.InvalidURL on a malformed REST base can't 500 the handshake). - BearerAuthMiddleware: resolve once per session (no Mcp-Session-Id + OAuth bearer); expose via the resolved_workspace_name ContextVar. - install_session_instructions: re-render the blob per session via create_initialization_options so the resolved workspace is visible at render time (the boot-time static render stays as the fallback). - instructions: fix the /api leak in the UI URL by deriving _opik_ui_url from opik_rest_base (single source of truth for where Opik lives). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alexkuzmik
approved these changes
Jun 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In OAuth-passthrough mode, opik-mcp forwards an opaque
opik_mcp_at_…bearer to opik-backend and lets the backend derive the workspace from the token row — so the MCP server never learns the workspace name. As a result the per-sessionInitializeResult.instructionsblob (the system-prompt-like context hosts inject once per session) could only name the static env workspace, and on a cloud OAuth session fell back to"default". It also leaked the REST/apisuffix into the user-facing Opik UI URL.This PR resolves the OAuth-authorized workspace name on the
initializehandshake and injects it into the per-session instructions blob, so an agent can truthfully state which workspace it is operating against — closing OPIK-7033 (defect #1 workspace, defect #2/apileak).What changed
oauth_identity.py(new) —resolve_workspace_name()does a one-shot token introspection:POST {opik_rest_base}/opik/auth-oauth(the backend'sOAuthValidateTokenResource), forwarding the inbound bearer verbatim, and readsworkspace_namefrom theValidatedTokenresponse. Best-effort by contract: never raises — any failure (unconfigured base, non-200, network error, malformed/invalid URL, bad JSON) returnsNoneso the handshake degrades gracefully to the static fallback.server.py—BearerAuthMiddleware— resolves the workspace once per session, gated on theinitializerequest (the only one with noMcp-Session-Id) + an OAuth-prefixed bearer. Tool calls carry a session id and pay zero introspection overhead. The name is exposed via a newresolved_workspace_nameContextVar and reset after the request.server.py—install_session_instructions()— wraps the lowlevel server'screate_initialization_options(mirrors the existinginstall_tools_listed_emitterpattern) to re-render the blob per session, so the resolved workspace is visible at render time. The boot-time static render remains as the stdio / fallback default.instructions.py— workspace precedence in the blob:Comet-Workspaceheader → OAuth-introspected name → static settings →"default". UI URL is now derived fromopik_rest_base(single source of truth) with the/apisuffix stripped, fixing the leak.auth_context.py— newresolved_workspace_nameContextVar, deliberately separate frominbound_workspaceso this read-only display value never leaks into outboundComet-Workspace/ data routing.opik_client.py— extractedopik_rest_base()as the single source of truth for the REST base, shared byresolve_opik_configand the new introspection path.config.py—OPIK_MCP_OAUTH_INTROSPECT_TIMEOUT_S(default 5s) bounds the introspection so a slow/unreachable backend can't stall the handshake.Why a pull-based introspection call (not scraping the token response)
The
/oauth/tokenresponse also carriesworkspace_name, butOAuthMetadataResourceadvertises an absolutetoken_endpointon opik-backend, so the host POSTs the code→token exchange directly to the backend — it never transits opik-mcp's proxy. There is no proxied response to scrape, scraping would fight RFC 6749 §5.1no-store, and it would be replica-fragile. Introspection reuses an existing endpoint and needs zero backend changes.Verification
POST https://dev.comet.com/opik/api/opik/auth-oauth→401(exists; a realopik_mcp_at_*token returns200+workspace_name).OAuthValidateTokenResourcereturns snake_caseValidatedToken{user_name, workspace_id, workspace_name, resource}.run_servertask, which is spawned (task_group.start) inside the middleware'scall_nextwhile the ContextVar is set;create_initialization_optionsruns in that task and inherits a copy of the context. Verified by reading the MCP SDKstreamable_http_managersource and an isolated anyio probe replicating the exact spawn/reset ordering.Code review (high-effort, adversarial)
A high-effort review surfaced two genuine issues, both fixed in this PR:
exceptcaught(httpx.HTTPError, ValueError)but nothttpx.InvalidURL(it subclassesException, notHTTPError), so a malformed REST base would escape and500theinitializehandshake. Broadened toexcept Exception(the documented "never raises" contract) + regression test._opik_ui_urlre-derived the base instead of reusing the newopik_rest_base; refactored to the single source of truth (behavior-preserving).Other flagged items were triaged as not-bugs: the doubled-
/opikintrospection URL is correct (live-verified 401 on dev — the endpoint genuinely lives at…/opik/api/opik/auth-oauth); the inline-introspection latency is causally required (the blob needs the value before render), bounded by timeout, and fails soft; the session-id heuristic and per-session render seam are intentional and mirror existing patterns.Testing
tests/test_oauth_identity.pycovers 200/401/network-error/non-JSON/missing-field/invalid-URL/base-unconfigured.tests/test_oauth_passthrough_mode.pycovers the middleware gate (resolve on initialize, skip on session-id, skip for API-key bearers) and ContextVar set/reset.tests/test_instructions.pycovers workspace precedence (header > resolved > settings > default) and the/api-strip.ruffandmypyclean.Deploy notes
OPIK_URL(orCOMET_URL_OVERRIDE) set so the REST base resolves — already required for existing OAuth data calls. If absent, the feature degrades gracefully to the static workspace rather than breaking.🤖 Generated with Claude Code