Skip to content

Commit 2722a36

Browse files
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 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 * ci: resolve deps against public PyPI on hosted runners 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
1 parent 3a4087b commit 2722a36

16 files changed

Lines changed: 2577 additions & 2043 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,23 @@ permissions:
1212
jobs:
1313
test:
1414
runs-on: ubuntu-latest
15+
env:
16+
# uv.lock pins packages to Databricks' internal pypi-proxy, which hosted
17+
# GitHub runners can't reach (downloads time out). Re-resolve against
18+
# public PyPI at CI time: UV_INDEX_URL + a `uv lock` before install.
19+
UV_INDEX_URL: https://pypi.org/simple
1520
steps:
1621
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
1722
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
23+
- run: uv lock
1824
- run: uv run pytest --ignore=tests/test_e2e.py --ignore=tests/test_e2e_tracing.py
1925

2026
e2e:
2127
runs-on: ubuntu-latest
2228
env:
29+
# See the test job: hosted runners can't reach the internal pypi-proxy,
30+
# so resolve against public PyPI (paired with the `uv lock` step below).
31+
UV_INDEX_URL: https://pypi.org/simple
2332
UCODE_TEST_WORKSPACE: ${{ secrets.UCODE_TEST_WORKSPACE }}
2433
DATABRICKS_HOST: ${{ secrets.UCODE_TEST_WORKSPACE }}
2534
# DATABRICKS_BEARER is the CI escape hatch: `databricks auth token`
@@ -47,6 +56,7 @@ jobs:
4756
opencode-ai
4857
@github/copilot
4958
@earendil-works/pi-coding-agent
59+
- run: uv lock
5060
- run: uv tool install .
5161
# Redirect stdin so any interactive `databricks auth login --no-browser`
5262
# fallback EOFs instead of hanging the runner. With DATABRICKS_BEARER

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,13 @@ Options are shown in this order:
8989
- Managed Databricks MCPs (Vector Search, UC Functions, etc.)
9090
- Custom MCP server URL
9191

92-
Discovered external MCP connections are listed directly. MCP auth uses a Databricks token that
93-
`ucode` sets when launching each tool.
92+
Discovered external MCP connections are listed directly.
93+
94+
Every Databricks MCP server is registered as a local **stdio** server that runs `ucode mcp-proxy`
95+
— a small bridge (shipped with `ucode`) between the coding tool and the Databricks
96+
streamable-HTTP MCP endpoint. The proxy mints a fresh OAuth token from your Databricks CLI profile
97+
on every request, so MCP auth is handled uniformly for every client and never expires mid-session.
98+
The coding tool starts and stops the proxy as a child process; there's nothing extra to run.
9499

95100
To set up an agent and its MCP server(s) in one command, pass `--mcp` with fully-qualified
96101
service name(s) to `ucode configure`:

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ readme = "README.md"
1010
requires-python = ">=3.12"
1111
dependencies = [
1212
"databricks-sql-connector>=3.6.0",
13+
# `ucode mcp-proxy` bridges a client's stdio MCP transport to a Databricks
14+
# streamable-HTTP MCP endpoint, injecting a freshly-minted OAuth bearer per
15+
# request. Uses the official MCP SDK's stdio server + streamable-HTTP client.
16+
"mcp>=1.28.0",
1317
"questionary>=2.0.0",
1418
"tomlkit>=0.13.0",
1519
"typer>=0.12.0",

src/ucode/agents/copilot.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,25 +99,27 @@ def build_runtime_env(workspace: str, model: str, token: str) -> dict[str, str]:
9999
return env
100100

101101

102-
def build_mcp_server_entry(url: str) -> dict:
102+
def build_mcp_server_entry(argv: list[str]) -> dict:
103+
# A `local` MCP server runs a stdio command; `command`/`args` split the
104+
# argv. ucode registers the `ucode mcp-proxy ...` bridge here so Copilot
105+
# never speaks HTTP+bearer directly — the proxy handles token refresh. The
106+
# OAUTH_TOKEN env Copilot still injects at launch is for MODEL auth, not MCP.
103107
return {
104-
"type": "http",
105-
"url": url,
106-
"headers": {
107-
"Authorization": "Bearer ${OAUTH_TOKEN}",
108-
},
108+
"type": "local",
109+
"command": argv[0],
110+
"args": list(argv[1:]),
109111
"tools": ["*"],
110112
}
111113

112114

113-
def write_mcp_server_config(name: str, url: str) -> bool:
115+
def write_mcp_server_config(name: str, argv: list[str]) -> bool:
114116
backup_existing_file(COPILOT_MCP_CONFIG_PATH, COPILOT_MCP_BACKUP_PATH)
115117
existing = read_json_safe(COPILOT_MCP_CONFIG_PATH)
116118
mcp_servers = existing.get("mcpServers")
117119
if not isinstance(mcp_servers, dict):
118120
mcp_servers = {}
119121
removed = name in mcp_servers
120-
mcp_servers[name] = build_mcp_server_entry(url)
122+
mcp_servers[name] = build_mcp_server_entry(argv)
121123
existing["mcpServers"] = mcp_servers
122124
write_json_file(COPILOT_MCP_CONFIG_PATH, existing)
123125
return removed

src/ucode/agents/opencode.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode"
3030
OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json"
3131
OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json"
32-
OPENCODE_MCP_AUTH_HEADER_VALUE = "Bearer {env:OAUTH_TOKEN}"
3332

3433
SPEC: ToolSpec = {
3534
"binary": "opencode",
@@ -193,25 +192,25 @@ def write_tool_config(
193192
return state, token
194193

195194

196-
def build_mcp_server_entry(url: str) -> dict:
195+
def build_mcp_server_entry(argv: list[str]) -> dict:
196+
# A `local` MCP server runs a command over stdio; `command` is the full
197+
# argv. ucode registers the `ucode mcp-proxy ...` bridge here so OpenCode
198+
# never speaks HTTP+bearer directly — the proxy mints fresh tokens itself.
197199
return {
198-
"type": "remote",
199-
"url": url,
200+
"type": "local",
201+
"command": list(argv),
200202
"enabled": True,
201-
"headers": {
202-
"Authorization": OPENCODE_MCP_AUTH_HEADER_VALUE,
203-
},
204203
}
205204

206205

207-
def write_mcp_server_config(name: str, url: str) -> bool:
206+
def write_mcp_server_config(name: str, argv: list[str]) -> bool:
208207
backup_existing_file(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH)
209208
existing = read_json_safe(OPENCODE_CONFIG_PATH)
210209
mcp_servers = existing.get("mcp")
211210
if not isinstance(mcp_servers, dict):
212211
mcp_servers = {}
213212
removed = name in mcp_servers
214-
mcp_servers[name] = build_mcp_server_entry(url)
213+
mcp_servers[name] = build_mcp_server_entry(argv)
215214
existing["mcp"] = mcp_servers
216215
write_json_file(OPENCODE_CONFIG_PATH, existing)
217216
return removed

src/ucode/cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,43 @@ def mcp_web_search_cmd() -> None:
868868
serve()
869869

870870

871+
@app.command("mcp-proxy", hidden=True)
872+
def mcp_proxy_cmd(
873+
url: Annotated[
874+
str,
875+
typer.Option("--url", help="Databricks streamable-HTTP MCP endpoint to forward to."),
876+
],
877+
host: Annotated[
878+
str | None,
879+
typer.Option(
880+
"--host", help="Workspace URL for token minting. Defaults to the saved workspace."
881+
),
882+
] = None,
883+
profile: Annotated[
884+
str | None, typer.Option("--profile", help="Databricks CLI profile.")
885+
] = None,
886+
use_pat: Annotated[
887+
bool, typer.Option("--use-pat", help="Use the profile's static PAT instead of OAuth.")
888+
] = False,
889+
) -> None:
890+
"""Bridge a coding agent's stdio MCP transport to a Databricks MCP endpoint.
891+
892+
Each configured client spawns this as a local stdio MCP server (see
893+
`ucode configure mcp`); it forwards messages to ``--url`` and injects a
894+
freshly-minted OAuth bearer on every upstream request, so the token never
895+
expires mid-session. Not meant for interactive use — the agent manages this
896+
process's lifecycle."""
897+
from ucode.mcp_proxy import serve
898+
899+
state = load_state()
900+
workspace = host or state.get("workspace")
901+
if not workspace:
902+
print_err("No workspace configured. Run `ucode configure` first.")
903+
raise typer.Exit(1)
904+
profile = profile or state.get("profile")
905+
serve(url, workspace, profile, use_pat=use_pat or bool(state.get("use_pat")))
906+
907+
871908
@app.command("auth-token", hidden=True)
872909
def auth_token_cmd(
873910
host: Annotated[

src/ucode/databricks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,27 @@ def build_auth_token_argv(
11381138
return argv
11391139

11401140

1141+
def build_mcp_proxy_argv(
1142+
url: str, workspace: str, profile: str | None = None, *, use_pat: bool = False
1143+
) -> list[str]:
1144+
"""Argv for the stdio MCP bridge: `ucode mcp-proxy --url ... --host ...`.
1145+
1146+
Every coding agent registers this single command as a local stdio MCP
1147+
server instead of a per-client HTTP endpoint with a bearer header. The proxy
1148+
forwards to ``url`` and mints a fresh OAuth token on each upstream request,
1149+
so tokens never expire mid-session — the client only ever spawns a process,
1150+
which keeps registration uniform across CLIs that disagree on HTTP-auth
1151+
syntax. Like `build_auth_token_argv`, this resolves the absolute `ucode`
1152+
path and passes plain arguments (no shell), so it runs identically on every
1153+
platform."""
1154+
argv = [_ucode_binary(), "mcp-proxy", "--url", url, "--host", workspace.rstrip("/")]
1155+
if profile:
1156+
argv += ["--profile", profile]
1157+
if use_pat:
1158+
argv.append("--use-pat")
1159+
return argv
1160+
1161+
11411162
def build_auth_shell_command(
11421163
workspace: str, profile: str | None = None, *, use_pat: bool = False
11431164
) -> str:

0 commit comments

Comments
 (0)