Skip to content

Route all MCP clients through a uniform ucode mcp-proxy stdio bridge#201

Open
sunishsheth2009 wants to merge 2 commits into
databricks:mainfrom
sunishsheth2009:sunish-sheth_data/mcp-proxy
Open

Route all MCP clients through a uniform ucode mcp-proxy stdio bridge#201
sunishsheth2009 wants to merge 2 commits into
databricks:mainfrom
sunishsheth2009:sunish-sheth_data/mcp-proxy

Conversation

@sunishsheth2009

Copy link
Copy Markdown
Collaborator

Summary

Replaces the per-client MCP authentication with one uniform mechanism: every coding agent registers Databricks MCP servers as an identical local stdio command — ucode mcp-proxy --url <endpoint> --host <workspace> --profile <profile> — and a small bridge (shipped in ucode) forwards to the Databricks streamable-HTTP MCP endpoint, minting a fresh OAuth token from the CLI profile on every request.

Why

Previously each client got a different HTTP-bearer registration (Bearer ${OAUTH_TOKEN} header for claude/gemini/copilot, --bearer-token-env-var for codex, {env:OAUTH_TOKEN} for opencode). The token was frozen at launch and expired (~1h) mid-session, and the clients disagree on auth config with only Claude exposing a per-request hook. This was the source of the inconsistency and the refresh problem raised in review.

A stdio proxy resolves all of it in one place:

  • Uniform — the same registration for claude, codex, gemini, opencode, copilot (and cursor, in the follow-up). No per-client auth logic.
  • Self-refreshing — the proxy mints a fresh token per upstream request; never stale mid-session.
  • Clean lifecycle — the client spawns the proxy as a child process and reaps it on exit. ucode owns no long-lived process and no background refresh thread. The proxy is an invisible implementation detail baked into config; users never run it.
  • No external package — ucode ships its own proxy (built on the official mcp SDK), rather than depending on uvx uc-mcp-proxy fetched at runtime.

Changes

  • src/ucode/mcp_proxy.py (new) — stdio↔streamable-HTTP bridge. An httpx.Auth injects a fresh get_databricks_token(...) bearer per request; a transport-level message pump forwards both directions, so new MCP methods/capabilities pass through untouched.
  • cli.py — hidden ucode mcp-proxy --url --host --profile --use-pat command.
  • databricks.pybuild_mcp_proxy_argv(...), mirroring build_auth_token_argv.
  • mcp.pyadd_{claude,codex,gemini}_mcp_server and the opencode/copilot config writers now register the stdio proxy argv; removed build_mcp_http_entry and the ${OAUTH_TOKEN} header wiring. add_claude_mcp_server stays polymorphic so the existing web_search stdio server (a dict entry with its own env) still registers via add-json.
  • pyproject.toml — adds mcp>=1.28.0.

Verification

Against a live workspace: ucode mcp-proxy completes the MCP stdio handshake (initialize OK, tools/list returns the endpoint's 4 tools); ucode's real codex registration lists the proxy as an enabled stdio server with the expected argv. Tests: 864 pass (the 2 test_e2e_user_agent failures are pre-existing, network-dependent, unrelated); ruff and ty clean.

Relationship to other PRs

This supersedes the per-client auth approaches:

This pull request and its description were written by Isaac.

@sunishsheth2009

Copy link
Copy Markdown
Collaborator Author

Why a local proxy, and not a per-client auth hook, mcp-remote, or a hosted gateway?

A reviewer asked a fair question: Claude Code already auto-refreshes creds on 401 — do we even need this? Short answer: that refresh is inference-only, and every alternative that avoids a local bridge requires the MCP endpoint to speak OAuth (which Databricks managed MCP does not, yet). Details below, since this is the core design decision.

What we're doing, concretely

Every Databricks MCP server is registered in each client's config as a local stdio server that runs one command — ucode mcp-proxy --url <endpoint> --host <ws> --profile <p>. There is exactly one proxy implementation and one arg builder (build_mcp_proxy_argv); every client (Claude, Codex, Gemini, OpenCode, Copilot, Cursor) registers that same command — only the per-client registration syntax differs. The client spawns/reaps the proxy as a child process, so ucode owns no long-lived process.

The proxy bridges stdio ↔ the Databricks streamable-HTTP MCP endpoint, and on the outbound side it attaches auth via an httpx.Auth whose auth_flow calls get_databricks_token(workspace, profile) per request. get_databricks_token returns a cached token and re-mints from the local Databricks CLI profile (OAuth) as it nears expiry. Net effect: no token is ever written into any client config, and the token handed to Databricks is always fresh — refreshed proactively per request, not reactively on-401, so there's no expiry window.

Why this way

The design goal was "fix all clients the same way, no per-client auth logic." A per-request local mint is the only mechanism that satisfies that and works against today's endpoint: the token lives in one place (the proxy), refresh is uniform, and clients need zero auth capability of their own — they just spawn a stdio command like any other MCP server. The rest of this comment is the evidence that the alternatives don't meet that bar.

The one thing everything hinges on

Does the Databricks managed-MCP endpoint expose standard MCP OAuth (authorization-server metadata + refresh)? Today it does not — attempting the OAuth path yields Incompatible auth server: does not support dynamic client registration (the DCR error). Every option below is gated on that.

Client-native "refresh on 401" is inference-only, and MCP is a separate code path

Claude Code refreshes the apiKeyHelper credential on 401 — for model/inference traffic. MCP auth is a different path:

Claude Code traffic Mechanism On 401
Inference apiKeyHelper ✅ auto-refresh (401 + every 5 min)
MCP — static header headers.Authorization ❌ connection just fails, no refresh
MCP — OAuth OAuth 2.0 token store ✅ refresh — but needs DCR (endpoint lacks it)
MCP — headersHelper per-request script ✅ re-runs on 401 (Claude ≥ v2.1.193) — Claude-only

Source: Claude Code auth docs + MCP docs.

Codex has no MCP auth-refresh hook (verified against source)

Codex's [model_providers.*.auth] command + refresh_interval_ms (the inference helper) has no MCP equivalent. [mcp_servers.*] supports only bearer_token_env_var / http_headers / env_http_headers — all read once at launch and frozen (codex-rs/codex-mcp/src/rmcp_client.rs::resolve_bearer_token() does a single env::var() at client construction; no re-read, no on-401 re-resolve) — plus auth = "oauth", which refreshes but needs an OAuth authorization server. The AuthManager-driven refresh that does exist is gated to Codex's own built-in Apps server, not user MCP servers.

mcp-remote doesn't help for our case either

mcp-remote (geelen/mcp-remote) is the same stdio↔HTTP bridge shape. Its --header "Authorization: Bearer ${TOKEN}" env-substitution happens once at startup (src/lib/utils.ts:916), then frozen — same expiry problem. Its only auto-refresh is its OAuth-with-DCR path, which our endpoint can't use. So it would fall back to a frozen static token. It can't replace this proxy for the self-minted-bearer, no-DCR case — which is exactly why the proxy re-mints per request.

How competitors solve it (and why none drops in for us)

Product Pattern Refresh
Omni Client → Omni's remote MCP URL directly Client-native OAuth 2.1, or a long-lived static PAT
LiteLLM Central hosted gateway; client holds one long-lived key Gateway refreshes upstream OAuth server-side (client_credentials)
Composio Hosted per-user MCP URL + x-api-key Composio backend auto-refreshes upstream OAuth

Two industry camps, neither fits today:

  • Hosted gateway + one long-lived key (LiteLLM/Composio): the gateway holds a centrally-storable secret. We can't — our credential is a per-developer local Databricks CLI profile; nothing hosted can mint from a laptop's CLI.
  • Client-native OAuth 2.1 (Omni): requires the endpoint to be an OAuth authorization server with DCR — the exact thing our endpoint lacks.

Notably, none of them uses a static token that expires mid-session — they all either sit behind OAuth-with-refresh or a long-lived PAT.

Verdict

This proxy is best characterized as "mcp-remote, but with a local credential helper that mints a fresh token per request from the Databricks CLI profile." It is the only approach that (a) works from local developer credentials against a non-OAuth endpoint, and (b) is uniform across all clients (Claude, Codex, Gemini, OpenCode, Copilot, Cursor) with no token stored in any client config and no per-client custom auth logic — which was the explicit design goal. Per-client hooks (headersHelper for Claude, static tokens elsewhere) would reintroduce exactly the divergence we set out to remove, and would still leave the non-Claude clients broken on expiry.

Exit path (how this proxy eventually goes away)

The day the Databricks managed-MCP endpoint supports standard MCP OAuth (metadata discovery + DCR + refresh), this whole problem dissolves: every client refreshes natively (Claude, Codex auth="oauth", Cursor, or others via mcp-remote), and we can delete the proxy. That's the industry end state (Omni's model). Filing that ask with the MCP endpoint team is the strategic fix; this proxy is the pragmatic uniform answer until then.

Note: the proxy refreshes proactively (per request, token cached with TTL) rather than reactively on-401, so there's no expiry window and no cached-token staleness. Sources: Claude Code docs; openai/codex @ main source; geelen/mcp-remote source; Omni/LiteLLM/Composio docs (linked above).

AarushiShah-db
AarushiShah-db previously approved these changes Jul 24, 2026
Comment thread src/ucode/mcp_proxy.py
from ucode.databricks import get_databricks_token


class _DatabricksTokenAuth(httpx.Auth):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we add some unit tests for this file?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — added tests/test_mcp_proxy.py (8 tests) in the latest push:

  • _DatabricksTokenAuth.auth_flow: injects Authorization: Bearer <token>, calls get_databricks_token with the right workspace/profile, and mints a fresh token per request (verifies a rotated token is picked up mid-session).
  • _pump: forwards all messages in order and closes the destination stream when the source exhausts (the teardown path that lets the bridge shut down cleanly).
  • serve: wires the parsed args into anyio.run(_run, ...) and applies the profile=None / use_pat=False defaults.

Full suite: 929 passed (the 2 test_e2e_user_agent failures are pre-existing live-gateway network tests, unrelated). Also rebased onto latest master (which now includes #199) and dropped the throwaway ci-retrigger commit.

AarushiShah-db
AarushiShah-db previously approved these changes Jul 24, 2026
Every coding agent now registers Databricks MCP servers as one identical local
stdio command — `ucode mcp-proxy --url <endpoint> --host <ws> --profile <p>` —
instead of per-client HTTP-bearer entries. This replaces five divergent auth
paths (claude `${OAUTH_TOKEN}` header, codex `--bearer-token-env-var`, gemini
`--header`, opencode `{env:OAUTH_TOKEN}`, copilot `Bearer ${OAUTH_TOKEN}`), none
of which refreshed the token mid-session, with a single mechanism.

Why a proxy: MCP clients disagree on HTTP-auth config and none expose a
per-request auth hook (except Claude's headersHelper), so a static bearer froze
at launch and expired after ~1h. A local stdio proxy sidesteps all of that: the
client spawns it as a child process (and reaps it — ucode owns no long-lived
process or refresh thread), and the proxy mints a fresh OAuth token from the
Databricks CLI profile on every upstream request. The proxy is an invisible
implementation detail baked into each client's config; users never invoke it.

- src/ucode/mcp_proxy.py (new): stdio<->streamable-HTTP bridge built on the
  official `mcp` SDK. `httpx.Auth` injects a fresh `get_databricks_token(...)`
  bearer per request; a transport-level pump forwards messages both ways, so new
  MCP methods/capabilities pass through untouched.
- cli.py: hidden `ucode mcp-proxy --url --host --profile --use-pat` command.
- databricks.py: `build_mcp_proxy_argv(...)` (mirrors build_auth_token_argv).
- mcp.py: add_{claude,codex,gemini}_mcp_server + opencode/copilot writers now
  register the stdio proxy argv; removed build_mcp_http_entry / OAUTH_TOKEN
  header. add_claude_mcp_server stays polymorphic so the web_search stdio entry
  (a dict with its own env) still registers via add-json.
- pyproject: add `mcp>=1.28.0`.

Verified against a live workspace: `ucode mcp-proxy` handshakes stdio and returns
the endpoint's tools (initialize OK, tools/list OK); ucode's real codex
registration lists the proxy as an enabled stdio server. Tests: 864 pass
(2 pre-existing e2e network failures unrelated); ruff + ty clean.

Co-authored-by: Isaac
uv.lock pins packages to Databricks' internal pypi-proxy, which GitHub
hosted runners can't reach — dependency downloads time out (~44s x3 retries)
before any test runs, failing both the test and e2e jobs.

Set UV_INDEX_URL=https://pypi.org/simple on both jobs and add a `uv lock`
step before install so the lockfile is re-resolved against public PyPI at
CI time. Per Aarushi Shah's suggestion.

Co-authored-by: Isaac
@sunishsheth2009
sunishsheth2009 force-pushed the sunish-sheth_data/mcp-proxy branch from 99597ad to f4196ef Compare July 24, 2026 23:49
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