Route all MCP clients through a uniform ucode mcp-proxy stdio bridge#201
Route all MCP clients through a uniform ucode mcp-proxy stdio bridge#201sunishsheth2009 wants to merge 2 commits into
ucode mcp-proxy stdio bridge#201Conversation
Why a local proxy, and not a per-client auth hook,
|
| 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).
| from ucode.databricks import get_databricks_token | ||
|
|
||
|
|
||
| class _DatabricksTokenAuth(httpx.Auth): |
There was a problem hiding this comment.
can we add some unit tests for this file?
There was a problem hiding this comment.
Done — added tests/test_mcp_proxy.py (8 tests) in the latest push:
_DatabricksTokenAuth.auth_flow: injectsAuthorization: Bearer <token>, callsget_databricks_tokenwith 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 intoanyio.run(_run, ...)and applies theprofile=None/use_pat=Falsedefaults.
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.
7c20c45 to
fc3d2ba
Compare
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
99597ad to
f4196ef
Compare
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-varfor 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:
mcpSDK), rather than depending onuvx uc-mcp-proxyfetched at runtime.Changes
src/ucode/mcp_proxy.py(new) — stdio↔streamable-HTTP bridge. Anhttpx.Authinjects a freshget_databricks_token(...)bearer per request; a transport-level message pump forwards both directions, so new MCP methods/capabilities pass through untouched.cli.py— hiddenucode mcp-proxy --url --host --profile --use-patcommand.databricks.py—build_mcp_proxy_argv(...), mirroringbuild_auth_token_argv.mcp.py—add_{claude,codex,gemini}_mcp_serverand the opencode/copilot config writers now register the stdio proxy argv; removedbuild_mcp_http_entryand the${OAUTH_TOKEN}header wiring.add_claude_mcp_serverstays polymorphic so the existingweb_searchstdio server (a dict entry with its own env) still registers viaadd-json.pyproject.toml— addsmcp>=1.28.0.Verification
Against a live workspace:
ucode mcp-proxycompletes the MCP stdio handshake (initializeOK,tools/listreturns the endpoint's 4 tools); ucode's realcodexregistration lists the proxy as an enabled stdio server with the expected argv. Tests: 864 pass (the 2test_e2e_user_agentfailures are pre-existing, network-dependent, unrelated);ruffandtyclean.Relationship to other PRs
This supersedes the per-client auth approaches:
headersHelper) — the proxy handles Claude refresh uniformly.ucode configure --mcpone-shot flag #199 (ucode configure --mcp) will be rebased onto this so Cursor becomes just another proxy stdio target and--mcpregisters via the proxy.This pull request and its description were written by Isaac.