Skip to content
Merged
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
11 changes: 9 additions & 2 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,7 +82,7 @@ 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
Expand All @@ -97,6 +98,11 @@ streamable-HTTP MCP endpoint. The proxy mints a fresh OAuth token from your Data
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`.

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

Expand Down Expand Up @@ -160,6 +166,7 @@ ucode configure skills --location main.default,ml.prod --mcp
| `~/.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 src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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
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])
80 changes: 68 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 @@ -1144,6 +1145,39 @@ def pi_cmd(ctx: typer.Context, skip_preflight: SkipPreflightOption = False) -> N
_launch_tool("pi", ctx, skip_preflight=skip_preflight)


@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 @@ -1316,20 +1350,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)
elif mcp is not None:
# MCP-only: `--mcp` without --agent(s) (e.g. Cursor, which isn't a
# model agent, or adding MCP servers to an already-configured setup).
Expand Down
19 changes: 17 additions & 2 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from questionary.question import Question
from questionary.styles import merge_styles_default

from ucode.agents import copilot, gemini, opencode
from ucode.agents import copilot, cursor, gemini, opencode
from ucode.config_io import restore_file
from ucode.databricks import (
apply_pat_environment,
Expand Down Expand Up @@ -79,9 +79,17 @@
"display": "GitHub Copilot CLI",
"list_command": "copilot mcp list",
},
"cursor": {
"binary": "cursor-agent",
"display": "Cursor",
"list_command": "cursor-agent mcp list",
},
}
SKILLS_MCP_KIND = "skills"
SKILLS_MCP_SERVER_NAME = "databricks-skill-registry"
# MCP-only clients ucode never launches for model routing, so they never land in
# `available_tools`; they're eligible for MCP config purely on being installed.
MCP_ONLY_CLIENTS = ("cursor",)
EXTERNAL_MCP_SELECTION_PREFIX = "external:"
SQL_MCP_VALUE = "managed:sql"
GENIE_SPACE_SELECTION_PREFIX = "genie-space:"
Expand Down Expand Up @@ -263,7 +271,9 @@ def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[st
configured_tools = []
configured = set(configured_tools)
return [
client for client in MCP_CLIENTS if client in configured and client in installed_clients
client
for client in MCP_CLIENTS
if client in installed_clients and (client in configured or client in MCP_ONLY_CLIENTS)
]


Expand Down Expand Up @@ -303,6 +313,9 @@ def configure_client_mcp_server(
if client == "copilot":
removed = copilot.write_mcp_server_config(name, argv)
return [MCP_USER_SCOPE] if removed else []
if client == "cursor":
removed = cursor.write_mcp_server_config(name, argv)
return [MCP_USER_SCOPE] if removed else []
raise RuntimeError(f"Unsupported MCP client '{client}'.")


Expand All @@ -317,6 +330,8 @@ def remove_client_mcp_server(client: str, name: str) -> list[str]:
return [MCP_USER_SCOPE] if opencode.remove_mcp_server_config(name) else []
if client == "copilot":
return [MCP_USER_SCOPE] if copilot.remove_mcp_server_config(name) else []
if client == "cursor":
return [MCP_USER_SCOPE] if cursor.remove_mcp_server_config(name) else []
raise RuntimeError(f"Unsupported MCP client '{client}'.")


Expand Down
Loading
Loading