Skip to content

Commit ead7e39

Browse files
Add Cursor as an MCP client (#192)
Add Cursor Agent as an MCP-only client on the mcp-proxy base Cursor runs models on the user's own Cursor account, so ucode configures no models for it. It registers Databricks MCP servers in ~/.cursor/mcp.json using the same uniform mechanism as every other client: a local stdio server that runs `ucode mcp-proxy`, which mints a fresh OAuth token per request. So Cursor needs no token in its config and no launch-time token export. - agents/cursor.py: build/write/remove ~/.cursor/mcp.json entries + thin launch() - mcp.py: register cursor in MCP_CLIENTS + MCP_ONLY_CLIENTS; configure/remove branches - cli.py: `ucode cursor` launcher; accept `cursor` in `--agents` - README + tests (test_agent_cursor.py, cursor coverage in test_mcp.py) Co-authored-by: Isaac
1 parent 2722a36 commit ead7e39

7 files changed

Lines changed: 329 additions & 16 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ ucode gemini # Gemini CLI
2626
ucode opencode # OpenCode
2727
ucode copilot # GitHub Copilot CLI
2828
ucode pi # Pi
29+
ucode cursor # Cursor Agent (MCP only — see below)
2930
```
3031

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

54-
Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`.
55+
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).
5556

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

@@ -81,7 +82,7 @@ ucode configure --profiles DEFAULT --agents claude,codex --use-pat --skip-valida
8182
ucode configure mcp
8283
```
8384

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

8788
- Discovered external MCP connections
@@ -97,6 +98,11 @@ streamable-HTTP MCP endpoint. The proxy mints a fresh OAuth token from your Data
9798
on every request, so MCP auth is handled uniformly for every client and never expires mid-session.
9899
The coding tool starts and stops the proxy as a child process; there's nothing extra to run.
99100

101+
**Cursor** is MCP-only: `cursor-agent` runs models on your own Cursor account, so `ucode`
102+
configures no models for it — it only registers Databricks MCP servers in `~/.cursor/mcp.json`
103+
(via the same proxy). Include it with `ucode configure --agents cursor` or pick it in
104+
`ucode configure mcp`, then launch with `ucode cursor`.
105+
100106
To set up an agent and its MCP server(s) in one command, pass `--mcp` with fully-qualified
101107
service name(s) to `ucode configure`:
102108

@@ -160,6 +166,7 @@ ucode configure skills --location main.default,ml.prod --mcp
160166
| `~/.config/opencode/opencode.json` | OpenCode |
161167
| `~/.copilot/.env` | GitHub Copilot CLI |
162168
| `~/.pi/agent/models.json` | Pi |
169+
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
163170

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

src/ucode/agents/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252

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

55+
# Model-routing agents ucode configures end to end. Cursor is deliberately NOT
56+
# here: it runs models on the user's own Cursor account, so `normalize_tool`
57+
# rejects it and the model-config paths never see it. The `configure`/MCP flows
58+
# handle "cursor" separately as an MCP-only client (see MCP_ONLY_CLIENTS).
5559
TOOL_ALIASES = {
5660
"codex": "codex",
5761
"claude": "claude",

src/ucode/agents/cursor.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Cursor agent: registers Databricks MCP servers in ~/.cursor/mcp.json.
2+
3+
Cursor is an MCP-only integration. `cursor-agent` runs models on the user's own
4+
Cursor account and exposes no gateway base URL, so ucode configures no models
5+
for it (it stays out of `agents.__init__._MODULES`). What ucode does is register
6+
Databricks MCP servers in Cursor's config, using the same uniform mechanism as
7+
every other client: a local **stdio** server that runs `ucode mcp-proxy`, which
8+
bridges to the Databricks MCP endpoint and mints a fresh OAuth token per request
9+
(see `ucode.mcp_proxy`). So Cursor needs no token in its config and no launch-
10+
time token export — `cursor-agent` just spawns the proxy like any stdio server.
11+
12+
`cursor-agent` reads `~/.cursor/mcp.json` directly, so entries are merged into
13+
that shared file (preserving anything already there) and removed surgically,
14+
mirroring how Claude/Codex edit their shared config rather than restoring a
15+
whole-file backup.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from pathlib import Path
21+
22+
from ucode.config_io import read_json_safe, write_json_file
23+
from ucode.launcher import exec_or_spawn
24+
25+
CURSOR_BINARY = "cursor-agent"
26+
CURSOR_CONFIG_DIR = Path.home() / ".cursor"
27+
CURSOR_MCP_CONFIG_PATH = CURSOR_CONFIG_DIR / "mcp.json"
28+
29+
30+
def build_mcp_server_entry(argv: list[str]) -> dict:
31+
# Cursor's stdio MCP schema: `command` + `args`. ucode registers the
32+
# `ucode mcp-proxy ...` bridge here so the proxy handles auth/refresh.
33+
return {
34+
"command": argv[0],
35+
"args": list(argv[1:]),
36+
}
37+
38+
39+
def write_mcp_server_config(name: str, argv: list[str]) -> bool:
40+
"""Add (or replace) one MCP server entry in ~/.cursor/mcp.json.
41+
42+
Merges into the existing `mcpServers` map so unrelated entries the user
43+
already configured survive. Returns True when an entry with this name was
44+
already present (i.e. this was a replacement)."""
45+
existing = read_json_safe(CURSOR_MCP_CONFIG_PATH)
46+
mcp_servers = existing.get("mcpServers")
47+
if not isinstance(mcp_servers, dict):
48+
mcp_servers = {}
49+
removed = name in mcp_servers
50+
mcp_servers[name] = build_mcp_server_entry(argv)
51+
existing["mcpServers"] = mcp_servers
52+
write_json_file(CURSOR_MCP_CONFIG_PATH, existing)
53+
return removed
54+
55+
56+
def remove_mcp_server_config(name: str) -> bool:
57+
"""Surgically remove one MCP server entry from ~/.cursor/mcp.json.
58+
59+
Returns True when an entry was removed, False when it wasn't present."""
60+
existing = read_json_safe(CURSOR_MCP_CONFIG_PATH)
61+
mcp_servers = existing.get("mcpServers")
62+
if not isinstance(mcp_servers, dict) or name not in mcp_servers:
63+
return False
64+
mcp_servers.pop(name)
65+
existing["mcpServers"] = mcp_servers
66+
write_json_file(CURSOR_MCP_CONFIG_PATH, existing)
67+
return True
68+
69+
70+
def launch(state: dict, tool_args: list[str]) -> None:
71+
"""Hand the terminal to `cursor-agent`.
72+
73+
No token wiring here: the Databricks MCP servers in ~/.cursor/mcp.json run
74+
`ucode mcp-proxy`, which authenticates itself, so `ucode cursor` is a thin
75+
convenience wrapper over `cursor-agent` (kept for symmetry with the other
76+
`ucode <agent>` launchers)."""
77+
exec_or_spawn([CURSOR_BINARY, *tool_args])

src/ucode/cli.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import shutil
67
from typing import Annotated
78

89
import typer
@@ -1144,6 +1145,39 @@ def pi_cmd(ctx: typer.Context, skip_preflight: SkipPreflightOption = False) -> N
11441145
_launch_tool("pi", ctx, skip_preflight=skip_preflight)
11451146

11461147

1148+
@app.command("cursor", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
1149+
def cursor_cmd(ctx: typer.Context) -> None:
1150+
"""Launch Cursor Agent.
1151+
1152+
Cursor is MCP-only: `cursor-agent` runs models on your own Cursor account, so
1153+
ucode configures no models for it. Its Databricks MCP servers (added via
1154+
`ucode configure mcp`) run `ucode mcp-proxy`, which authenticates itself — so
1155+
this command is a thin convenience wrapper over `cursor-agent`, kept for
1156+
symmetry with the other `ucode <agent>` launchers.
1157+
"""
1158+
from ucode.agents import cursor
1159+
1160+
try:
1161+
if not shutil.which(cursor.CURSOR_BINARY):
1162+
raise RuntimeError(
1163+
f"`{cursor.CURSOR_BINARY}` was not found on PATH. Install Cursor Agent "
1164+
"(https://cursor.com/cli), then re-run `ucode cursor`."
1165+
)
1166+
print_section("ucode with Cursor")
1167+
print_note(
1168+
"Cursor runs models on your Cursor account; its Databricks MCP servers "
1169+
"authenticate through `ucode mcp-proxy`."
1170+
)
1171+
print_success("Starting Cursor Agent")
1172+
cursor.launch(load_state(), ctx.args)
1173+
except RuntimeError as exc:
1174+
print_err(str(exc))
1175+
raise typer.Exit(1) from None
1176+
except KeyboardInterrupt:
1177+
print_err("Interrupted.")
1178+
raise typer.Exit(130) from None
1179+
1180+
11471181
@configure_app.callback(invoke_without_command=True)
11481182
def configure(
11491183
ctx: typer.Context,
@@ -1316,20 +1350,42 @@ def configure(
13161350
**skip_kwargs,
13171351
)
13181352
elif agents is not None:
1319-
selected_tools = _parse_agents_option(agents)
1320-
if workspace_entries is None:
1321-
configure_workspace_command(
1322-
selected_tools=selected_tools,
1323-
prompt_optional_updates=prompt_optional_updates,
1324-
**skip_kwargs,
1353+
# Cursor is MCP-only (no model routing), so it can't go through the
1354+
# model-agent configure path. Split it out: model agents configure
1355+
# normally; cursor only needs workspace state established here, and
1356+
# its MCP servers are added separately via `ucode configure mcp`
1357+
# (which picks cursor up through MCP_ONLY_CLIENTS). If cursor is the
1358+
# only agent, do a workspace-only configure so that later `configure
1359+
# mcp` run has a current workspace to target.
1360+
requested = [a.strip().lower() for a in agents.split(",") if a.strip()]
1361+
wants_cursor = "cursor" in requested
1362+
model_agent_names = ",".join(a for a in requested if a != "cursor")
1363+
if model_agent_names:
1364+
selected_tools = _parse_agents_option(model_agent_names)
1365+
if workspace_entries is None:
1366+
configure_workspace_command(
1367+
selected_tools=selected_tools,
1368+
prompt_optional_updates=prompt_optional_updates,
1369+
**skip_kwargs,
1370+
)
1371+
else:
1372+
configure_workspace_command(
1373+
selected_tools=selected_tools,
1374+
workspaces=workspace_entries,
1375+
prompt_optional_updates=prompt_optional_updates,
1376+
**skip_kwargs,
1377+
)
1378+
elif wants_cursor:
1379+
# Cursor-only: establish workspace state without the model picker.
1380+
_configure_shared_workspace_states(
1381+
workspace_entries or [_prompt_for_configuration(None)],
1382+
tools=[],
1383+
force_login=not use_pat,
1384+
use_pat=use_pat,
13251385
)
13261386
else:
1327-
configure_workspace_command(
1328-
selected_tools=selected_tools,
1329-
workspaces=workspace_entries,
1330-
prompt_optional_updates=prompt_optional_updates,
1331-
**skip_kwargs,
1332-
)
1387+
# Neither model agents nor cursor -> empty/invalid --agents list.
1388+
_parse_agents_option(agents)
13331389
elif mcp is not None:
13341390
# MCP-only: `--mcp` without --agent(s) (e.g. Cursor, which isn't a
13351391
# model agent, or adding MCP servers to an already-configured setup).

src/ucode/mcp.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from questionary.question import Question
2525
from questionary.styles import merge_styles_default
2626

27-
from ucode.agents import copilot, gemini, opencode
27+
from ucode.agents import copilot, cursor, gemini, opencode
2828
from ucode.config_io import restore_file
2929
from ucode.databricks import (
3030
apply_pat_environment,
@@ -79,9 +79,17 @@
7979
"display": "GitHub Copilot CLI",
8080
"list_command": "copilot mcp list",
8181
},
82+
"cursor": {
83+
"binary": "cursor-agent",
84+
"display": "Cursor",
85+
"list_command": "cursor-agent mcp list",
86+
},
8287
}
8388
SKILLS_MCP_KIND = "skills"
8489
SKILLS_MCP_SERVER_NAME = "databricks-skill-registry"
90+
# MCP-only clients ucode never launches for model routing, so they never land in
91+
# `available_tools`; they're eligible for MCP config purely on being installed.
92+
MCP_ONLY_CLIENTS = ("cursor",)
8593
EXTERNAL_MCP_SELECTION_PREFIX = "external:"
8694
SQL_MCP_VALUE = "managed:sql"
8795
GENIE_SPACE_SELECTION_PREFIX = "genie-space:"
@@ -263,7 +271,9 @@ def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[st
263271
configured_tools = []
264272
configured = set(configured_tools)
265273
return [
266-
client for client in MCP_CLIENTS if client in configured and client in installed_clients
274+
client
275+
for client in MCP_CLIENTS
276+
if client in installed_clients and (client in configured or client in MCP_ONLY_CLIENTS)
267277
]
268278

269279

@@ -303,6 +313,9 @@ def configure_client_mcp_server(
303313
if client == "copilot":
304314
removed = copilot.write_mcp_server_config(name, argv)
305315
return [MCP_USER_SCOPE] if removed else []
316+
if client == "cursor":
317+
removed = cursor.write_mcp_server_config(name, argv)
318+
return [MCP_USER_SCOPE] if removed else []
306319
raise RuntimeError(f"Unsupported MCP client '{client}'.")
307320

308321

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

322337

0 commit comments

Comments
 (0)