diff --git a/README.md b/README.md index d97bdeb..8e5f4b6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: @@ -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 @@ -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`: @@ -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. diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 88cb9e0..0c4c59f 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -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", diff --git a/src/ucode/agents/cursor.py b/src/ucode/agents/cursor.py new file mode 100644 index 0000000..db56768 --- /dev/null +++ b/src/ucode/agents/cursor.py @@ -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 ` launchers).""" + exec_or_spawn([CURSOR_BINARY, *tool_args]) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 5aa9a29..847685e 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations +import shutil from typing import Annotated import typer @@ -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 ` 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, @@ -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). diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 2757a4b..cdbe4a4 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -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, @@ -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:" @@ -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) ] @@ -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}'.") @@ -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}'.") diff --git a/tests/test_agent_cursor.py b/tests/test_agent_cursor.py new file mode 100644 index 0000000..dcdf287 --- /dev/null +++ b/tests/test_agent_cursor.py @@ -0,0 +1,110 @@ +"""Tests for the Cursor MCP-only agent integration.""" + +from __future__ import annotations + +import json + +from ucode.agents import cursor + +WS = "https://example.databricks.com" +# The proxy argv ucode registers for every MCP server (see build_mcp_proxy_argv). +PROXY_ARGV = ["ucode", "mcp-proxy", "--url", f"{WS}/api/2.0/mcp/functions/system/ai"] + + +class TestMcpServerEntry: + def test_builds_stdio_command_entry_from_proxy_argv(self): + entry = cursor.build_mcp_server_entry(PROXY_ARGV) + # Cursor's stdio schema splits argv into command + args; no token here — + # the proxy authenticates itself. + assert entry == {"command": "ucode", "args": PROXY_ARGV[1:]} + assert "headers" not in entry + + +class TestWriteMcpServerConfig: + def test_writes_without_clobbering_existing_entries(self, tmp_path, monkeypatch): + config_file = tmp_path / "mcp.json" + monkeypatch.setattr(cursor, "CURSOR_MCP_CONFIG_PATH", config_file) + # A pre-existing Cursor config (e.g. the user's own proxy entry). + config_file.write_text( + json.dumps({"mcpServers": {"proxy": {"command": "mcp", "args": ["start"]}}}), + encoding="utf-8", + ) + + removed = cursor.write_mcp_server_config("databricks-system-ai", PROXY_ARGV) + + written = json.loads(config_file.read_text()) + assert removed is False + assert written["mcpServers"]["proxy"] == {"command": "mcp", "args": ["start"]} + assert written["mcpServers"]["databricks-system-ai"] == { + "command": "ucode", + "args": PROXY_ARGV[1:], + } + + def test_creates_config_when_absent(self, tmp_path, monkeypatch): + config_file = tmp_path / "mcp.json" + monkeypatch.setattr(cursor, "CURSOR_MCP_CONFIG_PATH", config_file) + + removed = cursor.write_mcp_server_config("databricks-sql", PROXY_ARGV) + + assert removed is False + assert json.loads(config_file.read_text())["mcpServers"]["databricks-sql"]["command"] == ( + "ucode" + ) + + def test_reports_replaced_entry(self, tmp_path, monkeypatch): + config_file = tmp_path / "mcp.json" + monkeypatch.setattr(cursor, "CURSOR_MCP_CONFIG_PATH", config_file) + config_file.write_text( + json.dumps({"mcpServers": {"databricks-sql": {"command": "old"}}}), + encoding="utf-8", + ) + + removed = cursor.write_mcp_server_config("databricks-sql", PROXY_ARGV) + + assert removed is True + assert ( + json.loads(config_file.read_text())["mcpServers"]["databricks-sql"]["args"] + == (PROXY_ARGV[1:]) + ) + + +class TestRemoveMcpServerConfig: + def test_removes_without_clobbering_others(self, tmp_path, monkeypatch): + config_file = tmp_path / "mcp.json" + monkeypatch.setattr(cursor, "CURSOR_MCP_CONFIG_PATH", config_file) + config_file.write_text( + json.dumps( + { + "mcpServers": { + "databricks-sql": {"command": "ucode"}, + "proxy": {"command": "keep"}, + } + } + ), + encoding="utf-8", + ) + + removed = cursor.remove_mcp_server_config("databricks-sql") + + written = json.loads(config_file.read_text()) + assert removed is True + assert "databricks-sql" not in written["mcpServers"] + assert written["mcpServers"]["proxy"] == {"command": "keep"} + + def test_returns_false_when_absent(self, tmp_path, monkeypatch): + config_file = tmp_path / "mcp.json" + monkeypatch.setattr(cursor, "CURSOR_MCP_CONFIG_PATH", config_file) + config_file.write_text(json.dumps({"mcpServers": {}}), encoding="utf-8") + + assert cursor.remove_mcp_server_config("databricks-sql") is False + + +class TestLaunch: + def test_execs_cursor_agent_without_token_wiring(self, monkeypatch): + # No OAUTH_TOKEN is set: the proxy handles auth, so launch is a thin exec. + execs: list[list[str]] = [] + monkeypatch.setattr(cursor, "exec_or_spawn", lambda argv: execs.append(argv)) + + cursor.launch({"workspace": WS}, ["--resume"]) + + assert execs == [["cursor-agent", "--resume"]] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index eb39141..c143a7a 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -230,6 +230,50 @@ def test_excludes_explicit_non_mcp_http_connections(self): ) == ["github-mcp"] +class TestCursorMcpClient: + def test_cursor_registered_as_mcp_only_client(self): + assert "cursor" in mcp.MCP_CLIENTS + assert mcp.MCP_CLIENTS["cursor"]["binary"] == "cursor-agent" + assert "cursor" in mcp.MCP_ONLY_CLIENTS + + def test_configure_dispatches_proxy_argv_to_cursor_writer(self, monkeypatch): + calls: list[tuple[str, list[str]]] = [] + monkeypatch.setattr( + mcp.cursor, + "write_mcp_server_config", + lambda name, argv: calls.append((name, argv)) or False, + ) + + removed_scopes = mcp.configure_client_mcp_server("cursor", "github", GH_URL, WS, "p") + + assert removed_scopes == [] + assert calls == [("github", _proxy_argv())] + + def test_configure_reports_user_scope_on_replace(self, monkeypatch): + monkeypatch.setattr(mcp.cursor, "write_mcp_server_config", lambda name, argv: True) + assert mcp.configure_client_mcp_server("cursor", "github", GH_URL, WS, "p") == [ + mcp.MCP_USER_SCOPE + ] + + def test_remove_dispatches_to_cursor_remover(self, monkeypatch): + calls: list[str] = [] + monkeypatch.setattr( + mcp.cursor, "remove_mcp_server_config", lambda name: calls.append(name) or True + ) + assert mcp.remove_client_mcp_server("cursor", "github-mcp") == [mcp.MCP_USER_SCOPE] + assert calls == ["github-mcp"] + + def test_eligible_when_installed_even_without_configured_tools(self): + # Cursor is MCP-only: it never appears in available_tools, so eligibility + # rests on the binary being installed (MCP_ONLY_CLIENTS). + clients = mcp.configured_mcp_clients({"available_tools": ["claude"]}, ["claude", "cursor"]) + assert "cursor" in clients + + def test_skipped_when_binary_not_installed(self): + clients = mcp.configured_mcp_clients({"available_tools": ["claude"]}, ["claude"]) + assert "cursor" not in clients + + class TestConfigureClientMcpServer: def test_configures_copilot_with_proxy_argv(self, monkeypatch): calls: list[tuple[str, list[str]]] = []