Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,13 @@ Options are shown in this order:
- Managed Databricks MCPs (Vector Search, UC Functions, etc.)
- Custom MCP server URL

Discovered external MCP connections are listed directly. MCP auth uses a Databricks token that
`ucode` sets when launching each tool.
Discovered external MCP connections are listed directly.

Every Databricks MCP server is registered as a local **stdio** server that runs `ucode mcp-proxy`
— a small bridge (shipped with `ucode`) between the coding tool and the Databricks
streamable-HTTP MCP endpoint. The proxy mints a fresh OAuth token from your Databricks CLI profile
on every request, so MCP auth is handled uniformly for every client and never expires mid-session.
The coding tool starts and stops the proxy as a child process; there's nothing extra to run.

---

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"databricks-sql-connector>=3.6.0",
# `ucode mcp-proxy` bridges a client's stdio MCP transport to a Databricks
# streamable-HTTP MCP endpoint, injecting a freshly-minted OAuth bearer per
# request. Uses the official MCP SDK's stdio server + streamable-HTTP client.
"mcp>=1.28.0",
"questionary>=2.0.0",
"tomlkit>=0.13.0",
"typer>=0.12.0",
Expand Down
18 changes: 10 additions & 8 deletions src/ucode/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,27 @@ def build_runtime_env(workspace: str, model: str, token: str) -> dict[str, str]:
return env


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


def write_mcp_server_config(name: str, url: str) -> bool:
def write_mcp_server_config(name: str, argv: list[str]) -> bool:
backup_existing_file(COPILOT_MCP_CONFIG_PATH, COPILOT_MCP_BACKUP_PATH)
existing = read_json_safe(COPILOT_MCP_CONFIG_PATH)
mcp_servers = existing.get("mcpServers")
if not isinstance(mcp_servers, dict):
mcp_servers = {}
removed = name in mcp_servers
mcp_servers[name] = build_mcp_server_entry(url)
mcp_servers[name] = build_mcp_server_entry(argv)
existing["mcpServers"] = mcp_servers
write_json_file(COPILOT_MCP_CONFIG_PATH, existing)
return removed
Expand Down
17 changes: 8 additions & 9 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode"
OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json"
OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json"
OPENCODE_MCP_AUTH_HEADER_VALUE = "Bearer {env:OAUTH_TOKEN}"

SPEC: ToolSpec = {
"binary": "opencode",
Expand Down Expand Up @@ -193,25 +192,25 @@ def write_tool_config(
return state, token


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


def write_mcp_server_config(name: str, url: str) -> bool:
def write_mcp_server_config(name: str, argv: list[str]) -> bool:
backup_existing_file(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH)
existing = read_json_safe(OPENCODE_CONFIG_PATH)
mcp_servers = existing.get("mcp")
if not isinstance(mcp_servers, dict):
mcp_servers = {}
removed = name in mcp_servers
mcp_servers[name] = build_mcp_server_entry(url)
mcp_servers[name] = build_mcp_server_entry(argv)
existing["mcp"] = mcp_servers
write_json_file(OPENCODE_CONFIG_PATH, existing)
return removed
Expand Down
37 changes: 37 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,43 @@ def mcp_web_search_cmd() -> None:
serve()


@app.command("mcp-proxy", hidden=True)
def mcp_proxy_cmd(
url: Annotated[
str,
typer.Option("--url", help="Databricks streamable-HTTP MCP endpoint to forward to."),
],
host: Annotated[
str | None,
typer.Option(
"--host", help="Workspace URL for token minting. Defaults to the saved workspace."
),
] = None,
profile: Annotated[
str | None, typer.Option("--profile", help="Databricks CLI profile.")
] = None,
use_pat: Annotated[
bool, typer.Option("--use-pat", help="Use the profile's static PAT instead of OAuth.")
] = False,
) -> None:
"""Bridge a coding agent's stdio MCP transport to a Databricks MCP endpoint.

Each configured client spawns this as a local stdio MCP server (see
`ucode configure mcp`); it forwards messages to ``--url`` and injects a
freshly-minted OAuth bearer on every upstream request, so the token never
expires mid-session. Not meant for interactive use — the agent manages this
process's lifecycle."""
from ucode.mcp_proxy import serve

state = load_state()
workspace = host or state.get("workspace")
if not workspace:
print_err("No workspace configured. Run `ucode configure` first.")
raise typer.Exit(1)
profile = profile or state.get("profile")
serve(url, workspace, profile, use_pat=use_pat or bool(state.get("use_pat")))


@app.command("auth-token", hidden=True)
def auth_token_cmd(
host: Annotated[
Expand Down
21 changes: 21 additions & 0 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,27 @@ def build_auth_token_argv(
return argv


def build_mcp_proxy_argv(
url: str, workspace: str, profile: str | None = None, *, use_pat: bool = False
) -> list[str]:
"""Argv for the stdio MCP bridge: `ucode mcp-proxy --url ... --host ...`.

Every coding agent registers this single command as a local stdio MCP
server instead of a per-client HTTP endpoint with a bearer header. The proxy
forwards to ``url`` and mints a fresh OAuth token on each upstream request,
so tokens never expire mid-session — the client only ever spawns a process,
which keeps registration uniform across CLIs that disagree on HTTP-auth
syntax. Like `build_auth_token_argv`, this resolves the absolute `ucode`
path and passes plain arguments (no shell), so it runs identically on every
platform."""
argv = [_ucode_binary(), "mcp-proxy", "--url", url, "--host", workspace.rstrip("/")]
if profile:
argv += ["--profile", profile]
if use_pat:
argv.append("--use-pat")
return argv


def build_auth_shell_command(
workspace: str, profile: str | None = None, *, use_pat: bool = False
) -> str:
Expand Down
101 changes: 60 additions & 41 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from ucode.config_io import restore_file
from ucode.databricks import (
apply_pat_environment,
build_mcp_proxy_argv,
build_mcp_service_url,
ensure_databricks_auth,
get_databricks_token,
Expand All @@ -48,7 +49,6 @@
spinner,
)

MCP_AUTH_TOKEN_ENV_VAR = "OAUTH_TOKEN"
MCP_USER_SCOPE = "user"
MCP_CLEANUP_SCOPES = ("local", "project", MCP_USER_SCOPE)
MCP_PICKER_VISIBLE_ROWS = 10
Expand Down Expand Up @@ -96,20 +96,19 @@
)


def build_mcp_http_entry(url: str) -> dict:
return {
"type": "http",
"url": url,
"headers": {
"Authorization": f"Bearer ${{{MCP_AUTH_TOKEN_ENV_VAR}}}",
},
}


def add_claude_mcp_server(name: str, entry: dict, scope: str = MCP_USER_SCOPE) -> None:
def add_claude_mcp_server(name: str, server: list[str] | dict, scope: str = MCP_USER_SCOPE) -> None:
# Two registration shapes share this helper. The proxy path passes an argv
# list (`ucode mcp-proxy ...`), registered via `claude mcp add ... -- <argv>`
# where `--` fences the proxy's own flags off from claude's parser. The
# web_search server (agents/claude.py) passes a full stdio entry dict with
# its own env, which only `add-json` can express — so a dict routes there.
if isinstance(server, dict):
cmd = ["claude", "mcp", "add-json", name, json.dumps(server), "-s", scope]
else:
cmd = ["claude", "mcp", "add", name, "-s", scope, "--", *server]
try:
subprocess.run(
["claude", "mcp", "add-json", name, json.dumps(entry), "-s", scope],
cmd,
check=True,
capture_output=True,
text=True,
Expand Down Expand Up @@ -146,19 +145,12 @@ def remove_claude_mcp_server(name: str, scope: str) -> bool:
raise RuntimeError(f"Failed to remove MCP server '{name}' via claude CLI.") from exc


def add_codex_mcp_server(name: str, url: str) -> None:
def add_codex_mcp_server(name: str, argv: list[str]) -> None:
# `--` fences the proxy argv off from codex's own flag parser, registering
# it as a stdio server (codex spawns the command and speaks MCP over it).
try:
subprocess.run(
[
"codex",
"mcp",
"add",
name,
"--url",
url,
"--bearer-token-env-var",
MCP_AUTH_TOKEN_ENV_VAR,
],
["codex", "mcp", "add", name, "--", *argv],
check=True,
capture_output=True,
text=True,
Expand Down Expand Up @@ -195,21 +187,21 @@ def _gemini_cli_env() -> dict[str, str]:
return env


def add_gemini_mcp_server(name: str, url: str) -> None:
def add_gemini_mcp_server(name: str, argv: list[str]) -> None:
# Register the proxy as a stdio server: `gemini mcp add <name> <cmd> <args…>
# --type stdio`. The scope/type flags trail the captured command + args.
try:
subprocess.run(
[
"gemini",
"mcp",
"add",
name,
url,
*argv,
"--type",
"http",
"stdio",
"--scope",
MCP_USER_SCOPE,
"--header",
f"Authorization: Bearer ${{{MCP_AUTH_TOKEN_ENV_VAR}}}",
],
check=True,
capture_output=True,
Expand Down Expand Up @@ -256,26 +248,38 @@ def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[st
]


def configure_client_mcp_server(client: str, name: str, url: str, entry: dict) -> list[str]:
def configure_client_mcp_server(
client: str,
name: str,
url: str,
workspace: str,
profile: str | None = None,
*,
use_pat: bool = False,
) -> list[str]:
# Every client registers the same `ucode mcp-proxy ...` stdio command; the
# proxy forwards to `url` and refreshes the Databricks token itself. Only the
# per-client registration syntax differs.
argv = build_mcp_proxy_argv(url, workspace, profile, use_pat=use_pat)
if client == "claude":
removed_scopes = [
scope for scope in MCP_CLEANUP_SCOPES if remove_claude_mcp_server(name, scope)
]
add_claude_mcp_server(name, entry, MCP_USER_SCOPE)
add_claude_mcp_server(name, argv, MCP_USER_SCOPE)
return removed_scopes
if client == "codex":
removed = remove_codex_mcp_server(name)
add_codex_mcp_server(name, url)
add_codex_mcp_server(name, argv)
return [MCP_USER_SCOPE] if removed else []
if client == "gemini":
removed = remove_gemini_mcp_server(name)
add_gemini_mcp_server(name, url)
add_gemini_mcp_server(name, argv)
return [MCP_USER_SCOPE] if removed else []
if client == "opencode":
removed = opencode.write_mcp_server_config(name, url)
removed = opencode.write_mcp_server_config(name, argv)
return [MCP_USER_SCOPE] if removed else []
if client == "copilot":
removed = copilot.write_mcp_server_config(name, url)
removed = copilot.write_mcp_server_config(name, argv)
return [MCP_USER_SCOPE] if removed else []
raise RuntimeError(f"Unsupported MCP client '{client}'.")

Expand Down Expand Up @@ -1019,6 +1023,10 @@ def apply_mcp_server_changes(
original_servers: list[dict],
working_servers: list[dict],
clients: list[str],
workspace: str,
profile: str | None = None,
*,
use_pat: bool = False,
) -> bool:
original_by_name = _servers_by_name(original_servers)
working_by_name = _servers_by_name(working_servers)
Expand All @@ -1037,9 +1045,8 @@ def apply_mcp_server_changes(
url = server.get("url")
if not isinstance(url, str) or not url:
continue
entry = build_mcp_http_entry(url)
for client in clients:
configure_client_mcp_server(client, name, url, entry)
configure_client_mcp_server(client, name, url, workspace, profile, use_pat=use_pat)
changed = True

return changed
Expand Down Expand Up @@ -1167,7 +1174,7 @@ def _resolve_location_mcp_servers(
candidate = {
"name": entry_name,
"url": build_mcp_service_url(workspace, full_name),
"auth": f"env:{MCP_AUTH_TOKEN_ENV_VAR}",
"auth": "proxy",
"clients": merged_clients,
}
if original is not None and original == candidate:
Expand Down Expand Up @@ -1238,7 +1245,12 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
workspace, profile, clients, location, original_mcp_servers_for_location, services
)
changed = apply_mcp_server_changes(
original_mcp_servers_for_location, working_mcp_servers, clients
original_mcp_servers_for_location,
working_mcp_servers,
clients,
workspace,
profile,
use_pat=bool(state.get("use_pat")),
)
if changed or original_mcp_servers_for_location != working_mcp_servers:
state["mcp_servers"] = working_mcp_servers
Expand Down Expand Up @@ -1320,13 +1332,20 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
{
"name": entry_name,
"url": url,
"auth": f"env:{MCP_AUTH_TOKEN_ENV_VAR}",
"auth": "proxy",
"clients": clients,
}
)
working_names.add(entry_name)

changed = apply_mcp_server_changes(original_mcp_servers, working_mcp_servers, clients)
changed = apply_mcp_server_changes(
original_mcp_servers,
working_mcp_servers,
clients,
workspace,
profile,
use_pat=bool(state.get("use_pat")),
)
if changed or original_mcp_servers != working_mcp_servers:
state["mcp_servers"] = working_mcp_servers
save_state(state)
Expand Down
Loading
Loading