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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ucode gemini # Gemini CLI
ucode opencode # OpenCode
ucode copilot # GitHub Copilot CLI
ucode pi # Pi
ucode cursor # Cursor Agent (MCP only — see below)
```

On first launch, `ucode` will prompt for your Databricks workspace URL, authenticate, and configure that tool automatically. Subsequent launches go straight to the agent.
Expand All @@ -51,7 +52,7 @@ To configure specific tools without the picker, pass a comma-separated list:
ucode configure --agents claude,codex
```

Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`.
Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`. `cursor` is also accepted (MCP-only — it registers Databricks MCP servers but configures no models).

To configure without the workspace picker, pass a comma-separated list of workspaces:

Expand Down Expand Up @@ -81,16 +82,26 @@ ucode configure --profiles DEFAULT --agents claude,codex --use-pat --skip-valida
ucode configure mcp
```

Add Databricks MCP servers to installed MCP-capable tools: Codex, Claude Code, Gemini CLI, OpenCode, and GitHub Copilot CLI.
Add Databricks MCP servers to installed MCP-capable tools: Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, and Cursor Agent.
Options are shown in this order:

- Discovered external MCP connections
- Databricks SQL
- 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.

**Cursor** is MCP-only: `cursor-agent` runs models on your own Cursor account, so `ucode`
configures no models for it — it only registers Databricks MCP servers in `~/.cursor/mcp.json`
(via the same proxy). Include it with `ucode configure --agents cursor` or pick it in
`ucode configure mcp`, then launch with `ucode cursor`.

---

Expand Down Expand Up @@ -120,6 +131,7 @@ Discovered external MCP connections are listed directly. MCP auth uses a Databri
| `~/.config/opencode/opencode.json` | OpenCode |
| `~/.copilot/.env` | GitHub Copilot CLI |
| `~/.pi/agent/models.json` | Pi |
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |

Existing files are backed up before being overwritten. `ucode revert` restores backups.

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
4 changes: 4 additions & 0 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@

TOOL_SPECS: dict[str, ToolSpec] = {name: module.SPEC for name, module in _MODULES.items()}

# Model-routing agents ucode configures end to end. Cursor is deliberately NOT
# here: it runs models on the user's own Cursor account, so `normalize_tool`
# rejects it and the model-config paths never see it. The `configure`/MCP flows
# handle "cursor" separately as an MCP-only client (see MCP_ONLY_CLIENTS).
TOOL_ALIASES = {
"codex": "codex",
"claude": "claude",
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
77 changes: 77 additions & 0 deletions src/ucode/agents/cursor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Cursor agent: registers Databricks MCP servers in ~/.cursor/mcp.json.

Cursor is an MCP-only integration. `cursor-agent` runs models on the user's own
Cursor account and exposes no gateway base URL, so ucode configures no models
for it (it stays out of `agents.__init__._MODULES`). What ucode does is register
Databricks MCP servers in Cursor's config, using the same uniform mechanism as
every other client: a local **stdio** server that runs `ucode mcp-proxy`, which
bridges to the Databricks MCP endpoint and mints a fresh OAuth token per request
(see `ucode.mcp_proxy`). So Cursor needs no token in its config and no launch-
time token export — `cursor-agent` just spawns the proxy like any stdio server.

`cursor-agent` reads `~/.cursor/mcp.json` directly, so entries are merged into
that shared file (preserving anything already there) and removed surgically,
mirroring how Claude/Codex edit their shared config rather than restoring a
whole-file backup.
"""

from __future__ import annotations

from pathlib import Path

from ucode.config_io import read_json_safe, write_json_file
from ucode.launcher import exec_or_spawn

CURSOR_BINARY = "cursor-agent"
CURSOR_CONFIG_DIR = Path.home() / ".cursor"
CURSOR_MCP_CONFIG_PATH = CURSOR_CONFIG_DIR / "mcp.json"


def build_mcp_server_entry(argv: list[str]) -> dict:
# Cursor's stdio MCP schema: `command` + `args`. ucode registers the
# `ucode mcp-proxy ...` bridge here so the proxy handles auth/refresh.
return {
"command": argv[0],
"args": list(argv[1:]),
}


def write_mcp_server_config(name: str, argv: list[str]) -> bool:
"""Add (or replace) one MCP server entry in ~/.cursor/mcp.json.

Merges into the existing `mcpServers` map so unrelated entries the user
already configured survive. Returns True when an entry with this name was
already present (i.e. this was a replacement)."""
existing = read_json_safe(CURSOR_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(argv)
existing["mcpServers"] = mcp_servers
write_json_file(CURSOR_MCP_CONFIG_PATH, existing)
return removed


def remove_mcp_server_config(name: str) -> bool:
"""Surgically remove one MCP server entry from ~/.cursor/mcp.json.

Returns True when an entry was removed, False when it wasn't present."""
existing = read_json_safe(CURSOR_MCP_CONFIG_PATH)
mcp_servers = existing.get("mcpServers")
if not isinstance(mcp_servers, dict) or name not in mcp_servers:
return False
mcp_servers.pop(name)
existing["mcpServers"] = mcp_servers
write_json_file(CURSOR_MCP_CONFIG_PATH, existing)
return True


def launch(state: dict, tool_args: list[str]) -> None:
"""Hand the terminal to `cursor-agent`.

No token wiring here: the Databricks MCP servers in ~/.cursor/mcp.json run
`ucode mcp-proxy`, which authenticates itself, so `ucode cursor` is a thin
convenience wrapper over `cursor-agent` (kept for symmetry with the other
`ucode <agent>` launchers)."""
exec_or_spawn([CURSOR_BINARY, *tool_args])
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
117 changes: 105 additions & 12 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import shutil
from typing import Annotated

import typer
Expand Down Expand Up @@ -735,6 +736,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 Expand Up @@ -952,6 +990,39 @@ def pi_cmd(ctx: typer.Context) -> None:
_launch_tool("pi", ctx)


@app.command("cursor", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def cursor_cmd(ctx: typer.Context) -> None:
"""Launch Cursor Agent.

Cursor is MCP-only: `cursor-agent` runs models on your own Cursor account, so
ucode configures no models for it. Its Databricks MCP servers (added via
`ucode configure mcp`) run `ucode mcp-proxy`, which authenticates itself — so
this command is a thin convenience wrapper over `cursor-agent`, kept for
symmetry with the other `ucode <agent>` launchers.
"""
from ucode.agents import cursor

try:
if not shutil.which(cursor.CURSOR_BINARY):
raise RuntimeError(
f"`{cursor.CURSOR_BINARY}` was not found on PATH. Install Cursor Agent "
"(https://cursor.com/cli), then re-run `ucode cursor`."
)
print_section("ucode with Cursor")
print_note(
"Cursor runs models on your Cursor account; its Databricks MCP servers "
"authenticate through `ucode mcp-proxy`."
)
print_success("Starting Cursor Agent")
cursor.launch(load_state(), ctx.args)
except RuntimeError as exc:
print_err(str(exc))
raise typer.Exit(1) from None
except KeyboardInterrupt:
print_err("Interrupted.")
raise typer.Exit(130) from None


@configure_app.callback(invoke_without_command=True)
def configure(
ctx: typer.Context,
Expand Down Expand Up @@ -1080,20 +1151,42 @@ def configure(
**skip_kwargs,
)
elif agents is not None:
selected_tools = _parse_agents_option(agents)
if workspace_entries is None:
configure_workspace_command(
selected_tools=selected_tools,
prompt_optional_updates=prompt_optional_updates,
**skip_kwargs,
# Cursor is MCP-only (no model routing), so it can't go through the
# model-agent configure path. Split it out: model agents configure
# normally; cursor only needs workspace state established here, and
# its MCP servers are added separately via `ucode configure mcp`
# (which picks cursor up through MCP_ONLY_CLIENTS). If cursor is the
# only agent, do a workspace-only configure so that later `configure
# mcp` run has a current workspace to target.
requested = [a.strip().lower() for a in agents.split(",") if a.strip()]
wants_cursor = "cursor" in requested
model_agent_names = ",".join(a for a in requested if a != "cursor")
if model_agent_names:
selected_tools = _parse_agents_option(model_agent_names)
if workspace_entries is None:
configure_workspace_command(
selected_tools=selected_tools,
prompt_optional_updates=prompt_optional_updates,
**skip_kwargs,
)
else:
configure_workspace_command(
selected_tools=selected_tools,
workspaces=workspace_entries,
prompt_optional_updates=prompt_optional_updates,
**skip_kwargs,
)
elif wants_cursor:
# Cursor-only: establish workspace state without the model picker.
_configure_shared_workspace_states(
workspace_entries or [_prompt_for_configuration(None)],
tools=[],
force_login=not use_pat,
use_pat=use_pat,
)
else:
configure_workspace_command(
selected_tools=selected_tools,
workspaces=workspace_entries,
prompt_optional_updates=prompt_optional_updates,
**skip_kwargs,
)
# Neither model agents nor cursor -> empty/invalid --agents list.
_parse_agents_option(agents)
else:
# Tool binaries are installed after the user picks which agents
# they want, in configure_workspace_command.
Expand Down
Loading
Loading