Skip to content

[OPIK-7033] Name the OAuth-authorized workspace in per-session MCP instructions#151

Merged
awkoy merged 1 commit into
mainfrom
awkoy/opik-7033-mcp-instructions-workspace-url
Jun 25, 2026
Merged

[OPIK-7033] Name the OAuth-authorized workspace in per-session MCP instructions#151
awkoy merged 1 commit into
mainfrom
awkoy/opik-7033-mcp-instructions-workspace-url

Conversation

@awkoy

@awkoy awkoy commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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-session InitializeResult.instructions blob (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 /api suffix into the user-facing Opik UI URL.

This PR resolves the OAuth-authorized workspace name on the initialize handshake 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 /api leak).

What changed

  • oauth_identity.py (new)resolve_workspace_name() does a one-shot token introspection: POST {opik_rest_base}/opik/auth-oauth (the backend's OAuthValidateTokenResource), forwarding the inbound bearer verbatim, and reads workspace_name from the ValidatedToken response. Best-effort by contract: never raises — any failure (unconfigured base, non-200, network error, malformed/invalid URL, bad JSON) returns None so the handshake degrades gracefully to the static fallback.
  • server.pyBearerAuthMiddleware — resolves the workspace once per session, gated on the initialize request (the only one with no Mcp-Session-Id) + an OAuth-prefixed bearer. Tool calls carry a session id and pay zero introspection overhead. The name is exposed via a new resolved_workspace_name ContextVar and reset after the request.
  • server.pyinstall_session_instructions() — wraps the lowlevel server's create_initialization_options (mirrors the existing install_tools_listed_emitter pattern) 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-Workspace header → OAuth-introspected name → static settings → "default". UI URL is now derived from opik_rest_base (single source of truth) with the /api suffix stripped, fixing the leak.
  • auth_context.py — new resolved_workspace_name ContextVar, deliberately separate from inbound_workspace so this read-only display value never leaks into outbound Comet-Workspace / data routing.
  • opik_client.py — extracted opik_rest_base() as the single source of truth for the REST base, shared by resolve_opik_config and the new introspection path.
  • config.pyOPIK_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/token response also carries workspace_name, but OAuthMetadataResource advertises an absolute token_endpoint on 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.1 no-store, and it would be replica-fragile. Introspection reuses an existing endpoint and needs zero backend changes.

Verification

  • Endpoint confirmed live against dev: POST https://dev.comet.com/opik/api/opik/auth-oauth401 (exists; a real opik_mcp_at_* token returns 200 + workspace_name).
  • Backend contract confirmed: OAuthValidateTokenResource returns snake_case ValidatedToken{user_name, workspace_id, workspace_name, resource}.
  • ContextVar propagation proven: the blob is rendered inside the SDK's run_server task, which is spawned (task_group.start) inside the middleware's call_next while the ContextVar is set; create_initialization_options runs in that task and inherits a copy of the context. Verified by reading the MCP SDK streamable_http_manager source 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:

  1. Fail-soft contract bug — the introspection except caught (httpx.HTTPError, ValueError) but not httpx.InvalidURL (it subclasses Exception, not HTTPError), so a malformed REST base would escape and 500 the initialize handshake. Broadened to except Exception (the documented "never raises" contract) + regression test.
  2. DRY_opik_ui_url re-derived the base instead of reusing the new opik_rest_base; refactored to the single source of truth (behavior-preserving).

Other flagged items were triaged as not-bugs: the doubled-/opik introspection 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

  • New tests/test_oauth_identity.py covers 200/401/network-error/non-JSON/missing-field/invalid-URL/base-unconfigured.
  • tests/test_oauth_passthrough_mode.py covers the middleware gate (resolve on initialize, skip on session-id, skip for API-key bearers) and ContextVar set/reset.
  • tests/test_instructions.py covers workspace precedence (header > resolved > settings > default) and the /api-strip.
  • Full suite: 1082 passed, 2 skipped; ruff and mypy clean.

Deploy notes

  • No backend change required.
  • Needs OPIK_URL (or COMET_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

…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>
@awkoy awkoy merged commit 501effc into main Jun 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants