commit 1d71b3f822bd0f18f4ba8b48a3a7dec8cb280c4b Author: nearxos Date: Fri Jun 19 08:08:14 2026 +0300 Add v1.1.0 with progress streaming, timeouts, and git validation. Surface Cursor run progress to stderr and response payloads, enforce CURSOR_TIMEOUT_SECONDS, validate git repos before delegate, and fix resume session cwd persistence. Includes docs, tests, and packaging. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..17fe7d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +CURSOR_API_KEY= +CURSOR_DEFAULT_MODEL=composer-2.5 +CURSOR_DEFAULT_CWD=/Users/nearxos/Projects +CURSOR_TIMEOUT_SECONDS=1800 +CURSOR_REQUIRE_GIT_REPO=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f4e0f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ + +# Local env / secrets +.env + +# Runtime data +.local/ + +# Editor +.DS_Store +.idea/ +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f1fc40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 1.1.0 — 2026-06-19 + +### Added +- **Progress streaming**: delegation runs emit step/status lines to stderr and include a `progress` array in the JSON response. +- **Timeout enforcement**: `CURSOR_TIMEOUT_SECONDS` is honored; overdue runs are cancelled. +- **Git repo validation**: optional `CURSOR_REQUIRE_GIT_REPO` (default `true`) checks `git rev-parse` before delegate. +- **Resume cwd fix**: `cursor_resume` preserves the original session `cwd` in session history. +- Documentation: `docs/CONFIGURATION.md`, expanded `README.md`. +- Unit tests for repo validation and session cwd lookup. + +### Changed +- `delegate_once` no longer uses `Agent.prompt`; uses streamed `send` + `wait` for observability. + +## 1.0.0 — 2026-06-18 + +- Initial MCP server with delegate, session, resume, list, and test tools. diff --git a/README.md b/README.md new file mode 100644 index 0000000..edc86a7 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# cursor-mcp + +MCP server for Hermes (and other MCP clients) to delegate coding tasks to **Cursor** via the [Cursor SDK](https://cursor.com/docs/sdk/python). + +## Features + +- **Delegate** one-shot coding tasks (`cursor_delegate`) +- **Session** + **resume** for multi-step work (`cursor_delegate_session`, `cursor_resume`) +- **Progress streaming** to stderr + `progress` array in responses +- **Timeout** enforcement via `CURSOR_TIMEOUT_SECONDS` +- **Git validation** before delegate (configurable) +- Session index for follow-up work + +## Quick start + +### 1. Install + +```bash +cd ~/Projects/cursor-mcp +python3 -m venv .venv +.venv/bin/pip install -e . +``` + +### 2. Configure API key + +Create a user API key in **Cursor Dashboard → API Keys**, then add to `~/.hermes/.env`: + +```bash +CURSOR_API_KEY=your-key-here +``` + +### 3. Register with Hermes + +```bash +hermes mcp add cursor \ + --command ~/.hermes/mcp_servers/cursor-mcp-env.sh \ + --env CURSOR_DEFAULT_CWD=/Users/you/Projects \ + --env CURSOR_DEFAULT_MODEL=composer-2.5 \ + --timeout 1900 +``` + +Or add manually to `~/.hermes/config.yaml` — see [docs/CONFIGURATION.md](docs/CONFIGURATION.md). + +### 4. Verify + +```bash +hermes mcp test cursor +``` + +## MCP tools + +| Tool | Use when | +|------|----------| +| `cursor_delegate` | One-shot task (bug fix, small change) | +| `cursor_delegate_session` | Multi-step work; returns `agent_id` | +| `cursor_resume` | Follow-up on existing Cursor session | +| `cursor_test_connection` | Verify API key + project path | +| `cursor_list_sessions` | Recent delegations with `agent_id` | +| `cursor_get_config` | Non-secret config + recent sessions | + +## Response shape (v1.1+) + +Delegation tools return JSON including: + +```json +{ + "status": "finished", + "run_id": "...", + "agent_id": "...", + "result": "...", + "duration_ms": 64200, + "git": {"branch": "main", "commit": "abc1234", "dirty": false}, + "progress": [ + {"kind": "status", "message": "running"}, + {"kind": "delta", "message": "started edit_file", "tool": "edit_file"} + ] +} +``` + +Live progress also appears on stderr as `[cursor-mcp] ...` (Hermes: `~/.hermes/logs/mcp-stderr.log`). + +## Configuration + +See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for all `CURSOR_*` environment variables. + +## Hermes skill + +Use the `cursor-delegation` skill in the `senior-coder` profile for prompt templates and orchestration patterns. + +## Development + +```bash +.venv/bin/python -m unittest discover -s tests -v +``` + +## Notes + +- Runs Cursor's **local** agent loop against files on disk — does not control the open Cursor IDE window. +- Billing follows your Cursor plan (SDK usage). +- Node.js 22+ is required by the Cursor SDK runtime. + +## License + +MIT diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..31f8f7d --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,104 @@ +# Configuration + +Environment variables use the `CURSOR_` prefix (via `pydantic-settings`). + +| Variable | Default | Description | +|----------|---------|-------------| +| `CURSOR_API_KEY` | *(empty)* | Cursor user API key. Prefer `~/.hermes/.env` when using Hermes launcher. | +| `CURSOR_DEFAULT_MODEL` | `composer-2.5` | Default Cursor model for delegations. | +| `CURSOR_DEFAULT_CWD` | `~/Projects` | Default project root when `cwd` is omitted. | +| `CURSOR_TIMEOUT_SECONDS` | `1800` | Max seconds per delegation/resume run before cancel + error. | +| `CURSOR_REQUIRE_GIT_REPO` | `true` | When true, `cwd` must be inside a git work tree before delegate. | +| `CURSOR_SESSIONS_PATH` | `~/.local/share/cursor-mcp/sessions.json` | Persisted session index for resume. | +| `CURSOR_AUDIT_LOG_PATH` | `~/.local/share/cursor-mcp/audit.jsonl` | JSONL audit trail. | + +## Hermes registration + +Recommended launcher (loads `CURSOR_*` from `~/.hermes/.env`): + +```yaml +mcp_servers: + cursor: + command: /Users/nearxos/.hermes/mcp_servers/cursor-mcp-env.sh + enabled: true + timeout: 1900 + env: + CURSOR_DEFAULT_CWD: /Users/nearxos/Projects + CURSOR_DEFAULT_MODEL: composer-2.5 + CURSOR_TIMEOUT_SECONDS: 1800 + CURSOR_REQUIRE_GIT_REPO: true +``` + +Set Hermes MCP `timeout` slightly above `CURSOR_TIMEOUT_SECONDS` so the server can return a structured timeout error. + +## Progress streaming + +During long runs, cursor-mcp writes human-readable lines to **stderr**: + +```text +[cursor-mcp] status: running +[cursor-mcp] delta: started read_file +[cursor-mcp] step: tool call: edit_file +``` + +Hermes captures these in `~/.hermes/logs/mcp-stderr.log`. + +The tool response JSON also includes: + +```json +{ + "status": "finished", + "progress": [ + {"ts": "...", "kind": "status", "message": "running"}, + {"ts": "...", "kind": "delta", "message": "started read_file", "tool": "read_file"} + ] +} +``` + +## Git validation + +When `CURSOR_REQUIRE_GIT_REPO=true` (default), delegate tools fail fast if `cwd` is not a git repo: + +```text +Working directory is not a git repository: /path. Initialize git or set CURSOR_REQUIRE_GIT_REPO=false +``` + +Successful validation adds repo metadata to the response `git` field (`root`, `branch`, `commit`, `dirty`). + +For scratch prototypes: + +```bash +export CURSOR_REQUIRE_GIT_REPO=false +``` + +## Timeouts + +On timeout the server attempts `run.cancel()` and returns: + +```text +Cursor run timed out after 1800s (run_id=...) +``` + +Tune per environment: + +- Fast patches: `CURSOR_TIMEOUT_SECONDS=600` +- Large refactors: `CURSOR_TIMEOUT_SECONDS=3600` + +## Session resume + +`cursor_delegate_session` and `cursor_resume` store sessions in `CURSOR_SESSIONS_PATH`. + +Each entry includes `agent_id`, `cwd`, `task_summary`, and `updated_at`. + +`cursor_resume` uses the stored `cwd` when updating session history (fixed in 1.1.0). + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `CURSOR_API_KEY is not set` | Add to `~/.hermes/.env`, restart Hermes desktop | +| `not a git repository` | `git init` in project or disable `CURSOR_REQUIRE_GIT_REPO` | +| Timeout errors | Increase `CURSOR_TIMEOUT_SECONDS` or narrow task scope | +| No progress in logs | Ensure stderr is captured; check `mcp-stderr.log` | + +See also the Hermes skill `autonomous-ai-agents/cursor-delegation`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..49b37ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cursor-mcp" +version = "1.1.0" +description = "MCP server for delegating coding tasks to Cursor via the Cursor SDK" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [{ name = "cursor-mcp" }] +keywords = ["mcp", "cursor", "hermes", "coding"] +dependencies = [ + "mcp>=1.0.0", + "cursor-sdk>=0.1.7", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.scripts] +cursor-mcp = "cursor_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/cursor_mcp"] diff --git a/src/cursor_mcp/__init__.py b/src/cursor_mcp/__init__.py new file mode 100644 index 0000000..6849410 --- /dev/null +++ b/src/cursor_mcp/__init__.py @@ -0,0 +1 @@ +__version__ = "1.1.0" diff --git a/src/cursor_mcp/audit.py b/src/cursor_mcp/audit.py new file mode 100644 index 0000000..7670497 --- /dev/null +++ b/src/cursor_mcp/audit.py @@ -0,0 +1,22 @@ +"""Audit log for Cursor delegations.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cursor_mcp.config import settings + + +def audit_log(event: str, payload: dict[str, Any]) -> None: + path = Path(settings.audit_log_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + record = { + "ts": datetime.now(timezone.utc).isoformat(), + "event": event, + **payload, + } + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, default=str) + "\n") diff --git a/src/cursor_mcp/client.py b/src/cursor_mcp/client.py new file mode 100644 index 0000000..e342d40 --- /dev/null +++ b/src/cursor_mcp/client.py @@ -0,0 +1,310 @@ +"""Cursor SDK wrapper used by MCP tools.""" + +from __future__ import annotations + +import os +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError +from pathlib import Path +from typing import Any + +from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions, Run, SendOptions + +from cursor_mcp.audit import audit_log +from cursor_mcp.config import settings +from cursor_mcp.errors import CursorMCPError +from cursor_mcp.progress import ProgressCollector +from cursor_mcp.repo import validate_git_repo +from cursor_mcp.sessions import get_session, list_sessions, save_session + + +def _require_api_key() -> str: + key = settings.api_key or os.environ.get("CURSOR_API_KEY", "") + if not key: + raise CursorMCPError( + "CURSOR_API_KEY is not set. Add it to ~/.hermes/.env or export it in the MCP env." + ) + return key + + +def _resolve_cwd(cwd: str = "") -> Path: + raw = (cwd or settings.default_cwd).strip() + path = Path(raw).expanduser().resolve() + if not path.exists(): + raise CursorMCPError(f"Working directory does not exist: {path}") + if not path.is_dir(): + raise CursorMCPError(f"Working directory is not a folder: {path}") + return path + + +def _options(*, cwd: Path, model: str = "") -> AgentOptions: + return AgentOptions( + api_key=_require_api_key(), + model=model or settings.default_model, + local=LocalAgentOptions(cwd=str(cwd)), + ) + + +def _send_options(*, model: str = "", collector: ProgressCollector | None = None) -> SendOptions: + return SendOptions( + model=model or None, + on_step=collector.on_step if collector else None, + on_delta=collector.on_delta if collector else None, + ) + + +def _format_run_result( + result: Any, + *, + agent_id: str | None = None, + progress: list[dict[str, Any]] | None = None, + repo: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "status": str(getattr(result, "status", "unknown")), + "run_id": getattr(result, "id", None), + "agent_id": getattr(result, "agent_id", None) or agent_id, + "result": getattr(result, "result", "") or "", + "duration_ms": getattr(result, "duration_ms", None), + "model": getattr(result, "model", None), + } + git = getattr(result, "git", None) + if git is not None: + payload["git"] = { + "branch": getattr(git, "branch", None), + "commit": getattr(git, "commit", None), + } + elif repo is not None: + payload["git"] = { + "branch": repo.get("branch"), + "commit": repo.get("commit"), + "root": repo.get("root"), + "dirty": repo.get("dirty"), + } + if progress: + payload["progress"] = progress + return payload + + +def _wait_run(run: Run, collector: ProgressCollector, timeout_seconds: int) -> Any: + if run._terminal_result is not None: + return run._terminal_result + + deadline = time.monotonic() + timeout_seconds + run.on_did_change_status(collector.on_status) + + for _ in run.events(): + if time.monotonic() > deadline: + _cancel_run(run) + raise CursorMCPError( + f"Cursor run timed out after {timeout_seconds}s (run_id={run.id or 'pending'})" + ) + + if run._terminal_result is not None: + return run._terminal_result + + remaining = max(1.0, deadline - time.monotonic()) + return _wait_live_run(run, remaining, timeout_seconds) + + +def _wait_live_run(run: Run, remaining_seconds: float, configured_timeout: int) -> Any: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run.client.wait_live_run, run.id) + try: + result = future.result(timeout=remaining_seconds) + except FuturesTimeoutError as exc: + _cancel_run(run) + raise CursorMCPError( + f"Cursor run timed out after {configured_timeout}s (run_id={run.id})" + ) from exc + + for callback in run._apply_result(result): + callback() + run._terminal_result = result + return result + + +def _cancel_run(run: Run) -> None: + try: + run.cancel() + except Exception: + pass + audit_log("run_cancelled", {"run_id": run.id, "agent_id": run.agent_id}) + + +def _execute_send( + *, + agent: Agent, + prompt: str, + cwd: Path, + model: str, + collector: ProgressCollector, +) -> dict[str, Any]: + run = agent.send(prompt, _send_options(model=model, collector=collector)) + result = _wait_run(run, collector, settings.timeout_seconds) + return _format_run_result( + result, + agent_id=getattr(agent, "id", None), + progress=collector.snapshot(), + ) + + +def delegate_once( + *, + prompt: str, + cwd: str = "", + model: str = "", + task_summary: str = "", +) -> dict[str, Any]: + if not prompt.strip(): + raise CursorMCPError("prompt is required") + workdir = _resolve_cwd(cwd) + repo = validate_git_repo(workdir, required=settings.require_git_repo) + collector = ProgressCollector() + audit_log( + "delegate_once_start", + { + "cwd": str(workdir), + "model": model or settings.default_model, + "summary": task_summary, + "timeout_seconds": settings.timeout_seconds, + }, + ) + agent = Agent.create(_options(cwd=workdir, model=model)) + try: + payload = _execute_send( + agent=agent, + prompt=prompt, + cwd=workdir, + model=model, + collector=collector, + ) + except CursorAgentError as exc: + audit_log("delegate_once_error", {"message": str(exc), "retryable": exc.is_retryable}) + raise CursorMCPError(f"Cursor agent failed to start: {exc}") from exc + finally: + agent.close() + + if payload.get("agent_id") and payload.get("run_id"): + save_session( + agent_id=str(payload["agent_id"]), + run_id=str(payload["run_id"]), + cwd=str(workdir), + task_summary=task_summary or prompt[:120], + status=str(payload["status"]), + result_preview=str(payload.get("result", "")), + ) + audit_log("delegate_once_done", {"status": payload.get("status"), "run_id": payload.get("run_id")}) + if repo is not None and "git" not in payload: + payload["git"] = repo + return payload + + +def delegate_session( + *, + prompt: str, + cwd: str = "", + model: str = "", + task_summary: str = "", +) -> dict[str, Any]: + """Create a durable Cursor agent, run first prompt, return agent_id for follow-ups.""" + if not prompt.strip(): + raise CursorMCPError("prompt is required") + workdir = _resolve_cwd(cwd) + repo = validate_git_repo(workdir, required=settings.require_git_repo) + collector = ProgressCollector() + audit_log( + "delegate_session_start", + { + "cwd": str(workdir), + "model": model or settings.default_model, + "summary": task_summary, + "timeout_seconds": settings.timeout_seconds, + }, + ) + try: + with Agent.create( + api_key=_require_api_key(), + model=model or settings.default_model, + local=LocalAgentOptions(cwd=str(workdir)), + ) as agent: + payload = _execute_send( + agent=agent, + prompt=prompt, + cwd=workdir, + model=model, + collector=collector, + ) + agent_id = payload.get("agent_id") + if agent_id and payload.get("run_id"): + save_session( + agent_id=str(agent_id), + run_id=str(payload["run_id"]), + cwd=str(workdir), + task_summary=task_summary or prompt[:120], + status=str(payload["status"]), + result_preview=str(payload.get("result", "")), + ) + except CursorAgentError as exc: + audit_log("delegate_session_error", {"message": str(exc), "retryable": exc.is_retryable}) + raise CursorMCPError(f"Cursor agent failed: {exc}") from exc + + audit_log("delegate_session_done", payload) + if repo is not None and "git" not in payload: + payload["git"] = repo + return payload + + +def resume_agent( + *, + agent_id: str, + prompt: str, + model: str = "", +) -> dict[str, Any]: + if not agent_id.strip(): + raise CursorMCPError("agent_id is required") + if not prompt.strip(): + raise CursorMCPError("prompt is required") + + prior = get_session(agent_id) + resume_cwd = (prior or {}).get("cwd") or settings.default_cwd + collector = ProgressCollector() + audit_log( + "resume_start", + {"agent_id": agent_id, "cwd": resume_cwd, "timeout_seconds": settings.timeout_seconds}, + ) + try: + with Agent.resume(agent_id, AgentOptions(api_key=_require_api_key(), model=model or None)) as agent: + payload = _execute_send( + agent=agent, + prompt=prompt, + cwd=Path(resume_cwd), + model=model, + collector=collector, + ) + except CursorAgentError as exc: + audit_log("resume_error", {"agent_id": agent_id, "message": str(exc)}) + raise CursorMCPError(f"Cursor resume failed: {exc}") from exc + + if payload.get("run_id"): + save_session( + agent_id=agent_id, + run_id=str(payload["run_id"]), + cwd=resume_cwd, + task_summary=prompt[:120], + status=str(payload.get("status", "")), + result_preview=str(payload.get("result", "")), + ) + audit_log("resume_done", payload) + return payload + + +def get_status() -> dict[str, Any]: + return { + "api_key_configured": bool(settings.api_key or os.environ.get("CURSOR_API_KEY")), + "default_model": settings.default_model, + "default_cwd": settings.default_cwd, + "timeout_seconds": settings.timeout_seconds, + "require_git_repo": settings.require_git_repo, + "recent_sessions": list_sessions(), + } diff --git a/src/cursor_mcp/config.py b/src/cursor_mcp/config.py new file mode 100644 index 0000000..e71b061 --- /dev/null +++ b/src/cursor_mcp/config.py @@ -0,0 +1,22 @@ +"""Configuration for cursor-mcp.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="CURSOR_", extra="ignore") + + api_key: str = "" + default_model: str = "composer-2.5" + default_cwd: str = str(Path.home() / "Projects") + timeout_seconds: int = 1800 + require_git_repo: bool = True + sessions_path: str = str(Path.home() / ".local/share/cursor-mcp/sessions.json") + audit_log_path: str = str(Path.home() / ".local/share/cursor-mcp/audit.jsonl") + + +settings = Settings() diff --git a/src/cursor_mcp/errors.py b/src/cursor_mcp/errors.py new file mode 100644 index 0000000..17cf900 --- /dev/null +++ b/src/cursor_mcp/errors.py @@ -0,0 +1,5 @@ +"""Shared cursor-mcp exceptions.""" + + +class CursorMCPError(Exception): + pass diff --git a/src/cursor_mcp/progress.py b/src/cursor_mcp/progress.py new file mode 100644 index 0000000..063c82c --- /dev/null +++ b/src/cursor_mcp/progress.py @@ -0,0 +1,114 @@ +"""Collect and emit Cursor run progress for Hermes MCP consumers.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from typing import Any + +from cursor_sdk.types import ( + ConversationStep, + InteractionUpdate, + StepCompletedUpdate, + StepStartedUpdate, + SummaryUpdate, + ToolCallCompletedUpdate, + ToolCallStartedUpdate, +) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _tool_label(tool_call: Any) -> str: + if not isinstance(tool_call, dict): + tool_call = getattr(tool_call, "__dict__", {}) or {} + if isinstance(tool_call, dict): + name = tool_call.get("name") or tool_call.get("toolName") or tool_call.get("type") + if name: + return str(name) + return "tool" + + +class ProgressCollector: + """Record progress events and mirror key lines to stderr (Hermes mcp-stderr.log).""" + + def __init__(self, *, emit_stderr: bool = True, max_events: int = 100) -> None: + self.emit_stderr = emit_stderr + self.max_events = max_events + self.events: list[dict[str, Any]] = [] + + def emit(self, kind: str, message: str, **extra: Any) -> None: + entry: dict[str, Any] = { + "ts": _utc_now(), + "kind": kind, + "message": message, + **extra, + } + self.events.append(entry) + if len(self.events) > self.max_events: + self.events = self.events[-self.max_events :] + if self.emit_stderr and message: + print(f"[cursor-mcp] {kind}: {message}", file=sys.stderr, flush=True) + + def on_status(self, status: str) -> None: + self.emit("status", status, status=status) + + def on_step(self, step: ConversationStep) -> None: + step_type = getattr(step, "type", "step") + if step_type == "toolCall": + message = step.message if isinstance(step.message, dict) else {} + label = _tool_label(message) + self.emit("step", f"tool call: {label}", step_type=step_type, tool=label) + return + if step_type == "assistantMessage": + text = getattr(getattr(step, "message", None), "text", "") or "" + preview = text.strip().replace("\n", " ")[:120] + if preview: + self.emit("step", f"assistant: {preview}", step_type=step_type) + return + if step_type == "thinkingMessage": + self.emit("step", "thinking", step_type=step_type) + return + if step_type == "shellConversationTurn": + self.emit("step", "shell command", step_type=step_type) + return + self.emit("step", step_type, step_type=step_type) + + def on_delta(self, update: InteractionUpdate) -> None: + update_type = getattr(update, "type", None) + if update_type is None and isinstance(update, dict): + update_type = update.get("type") + if update_type == "tool-call-started": + tool = _tool_label(getattr(update, "tool_call", {})) + self.emit("delta", f"started {tool}", update_type=update_type, tool=tool) + return + if update_type == "tool-call-completed": + tool = _tool_label(getattr(update, "tool_call", {})) + self.emit("delta", f"completed {tool}", update_type=update_type, tool=tool) + return + if update_type == "summary": + summary = getattr(update, "summary", "") or "" + preview = summary.strip().replace("\n", " ")[:160] + if preview: + self.emit("delta", preview, update_type=update_type) + return + if update_type == "step-started": + step_id = getattr(update, "step_id", None) + self.emit("delta", f"step {step_id} started", update_type=update_type, step_id=step_id) + return + if update_type == "step-completed": + step_id = getattr(update, "step_id", None) + duration = getattr(update, "step_duration_ms", None) + self.emit( + "delta", + f"step {step_id} completed ({duration}ms)" if duration else f"step {step_id} completed", + update_type=update_type, + step_id=step_id, + duration_ms=duration, + ) + return + + def snapshot(self) -> list[dict[str, Any]]: + return list(self.events) diff --git a/src/cursor_mcp/repo.py b/src/cursor_mcp/repo.py new file mode 100644 index 0000000..3979420 --- /dev/null +++ b/src/cursor_mcp/repo.py @@ -0,0 +1,57 @@ +"""Optional git repository validation before delegation.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +from cursor_mcp.errors import CursorMCPError + + +def validate_git_repo(workdir: Path, *, required: bool = True) -> dict[str, Any] | None: + """Verify ``workdir`` is inside a git work tree when ``required`` is true.""" + if not required: + return None + + inside = _run_git(workdir, ["rev-parse", "--is-inside-work-tree"]) + if inside.stdout.strip() != "true": + raise CursorMCPError( + f"Working directory is not a git repository: {workdir}. " + "Initialize git or set CURSOR_REQUIRE_GIT_REPO=false for scratch work." + ) + + root = _run_git(workdir, ["rev-parse", "--show-toplevel"]).stdout.strip() + branch = _run_git(workdir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() + commit = _run_git(workdir, ["rev-parse", "--short", "HEAD"]).stdout.strip() + dirty = _run_git(workdir, ["status", "--porcelain"]).stdout.strip() + + return { + "root": root, + "branch": branch, + "commit": commit, + "dirty": bool(dirty), + } + + +def _run_git(workdir: Path, args: list[str]) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + ["git", *args], + cwd=workdir, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except FileNotFoundError as exc: + raise CursorMCPError( + "git is not installed; install git or set CURSOR_REQUIRE_GIT_REPO=false" + ) from exc + except subprocess.TimeoutExpired as exc: + raise CursorMCPError("git validation timed out") from exc + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "git command failed").strip() + raise CursorMCPError(f"git validation failed: {detail}") + return result diff --git a/src/cursor_mcp/server.py b/src/cursor_mcp/server.py new file mode 100644 index 0000000..a76341e --- /dev/null +++ b/src/cursor_mcp/server.py @@ -0,0 +1,150 @@ +"""Cursor MCP server — delegate coding work to Cursor via the Cursor SDK.""" + +from __future__ import annotations + +import json +import os + +from mcp.server.fastmcp import FastMCP + +from cursor_mcp import __version__ +from cursor_mcp.errors import CursorMCPError +from cursor_mcp.client import delegate_once, delegate_session, get_status, resume_agent +from cursor_mcp.config import settings +from cursor_mcp.sessions import list_sessions + +mcp = FastMCP( + "cursor", + instructions=( + "Delegate software engineering work to Cursor's local agent via the Cursor SDK. " + "Use cursor_delegate for one-shot tasks (bug fix, small feature). " + "Use cursor_delegate_session when follow-ups are likely (multi-step refactors). " + "Use cursor_resume with agent_id for follow-up prompts on an existing Cursor session. " + "Always pass an absolute project path as cwd when known. " + "Requires CURSOR_API_KEY. This runs Cursor's agent loop locally against files on disk; " + "it does not control an open Cursor IDE window." + ), +) + + +def _json(data: object) -> str: + return json.dumps(data, indent=2, default=str) + + +@mcp.tool() +def cursor_get_config() -> str: + """Return cursor-mcp configuration and recent delegated sessions (no secrets).""" + try: + configured = bool(settings.api_key or os.environ.get("CURSOR_API_KEY")) + payload = { + "version": __version__, + "api_key_configured": configured, + "default_model": settings.default_model, + "default_cwd": settings.default_cwd, + "timeout_seconds": settings.timeout_seconds, + "require_git_repo": settings.require_git_repo, + "recent_sessions": list_sessions(), + } + return _json(payload) + except Exception as exc: + return _json({"status": "error", "message": str(exc)}) + + +@mcp.tool() +def cursor_test_connection(cwd: str = "") -> str: + """Verify CURSOR_API_KEY and local project path. Does not run a full coding task.""" + try: + from cursor_mcp.client import _require_api_key, _resolve_cwd + + _require_api_key() + workdir = _resolve_cwd(cwd) + return _json( + { + "status": "ok", + "message": "CURSOR_API_KEY is set and working directory is valid.", + "cwd": str(workdir), + "default_model": settings.default_model, + } + ) + except CursorMCPError as exc: + return _json({"status": "error", "message": str(exc)}) + + +@mcp.tool() +def cursor_delegate( + prompt: str, + cwd: str = "", + model: str = "", + task_summary: str = "", +) -> str: + """Run a one-shot coding task through Cursor's local agent. + + Args: + prompt: Full instructions for Cursor (scope, files, acceptance criteria). + cwd: Absolute path to the project/repo root. Defaults to CURSOR_DEFAULT_CWD. + model: Cursor model id (default composer-2.5). + task_summary: Short label for session history. + """ + try: + return _json( + delegate_once( + prompt=prompt, + cwd=cwd, + model=model, + task_summary=task_summary, + ) + ) + except CursorMCPError as exc: + return _json({"status": "error", "message": str(exc)}) + + +@mcp.tool() +def cursor_delegate_session( + prompt: str, + cwd: str = "", + model: str = "", + task_summary: str = "", +) -> str: + """Start a durable Cursor agent session for multi-step coding work. + + Returns agent_id — pass it to cursor_resume for follow-up instructions. + Prefer this for refactors, feature builds, or tasks needing iteration. + """ + try: + return _json( + delegate_session( + prompt=prompt, + cwd=cwd, + model=model, + task_summary=task_summary, + ) + ) + except CursorMCPError as exc: + return _json({"status": "error", "message": str(exc)}) + + +@mcp.tool() +def cursor_resume( + agent_id: str, + prompt: str, + model: str = "", +) -> str: + """Send a follow-up prompt to an existing Cursor agent session.""" + try: + return _json(resume_agent(agent_id=agent_id, prompt=prompt, model=model)) + except CursorMCPError as exc: + return _json({"status": "error", "message": str(exc)}) + + +@mcp.tool() +def cursor_list_sessions(limit: int = 10) -> str: + """List recent Cursor delegation sessions with agent_id for resume.""" + return _json({"sessions": list_sessions(limit=limit)}) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/cursor_mcp/sessions.py b/src/cursor_mcp/sessions.py new file mode 100644 index 0000000..0b016f0 --- /dev/null +++ b/src/cursor_mcp/sessions.py @@ -0,0 +1,64 @@ +"""Persist Cursor agent IDs for follow-up delegation.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cursor_mcp.config import settings + + +def _path() -> Path: + path = Path(settings.sessions_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def load_sessions() -> dict[str, Any]: + path = _path() + if not path.exists(): + return {"sessions": []} + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return {"sessions": []} + + +def save_session( + *, + agent_id: str, + run_id: str, + cwd: str, + task_summary: str, + status: str, + result_preview: str = "", +) -> dict[str, Any]: + data = load_sessions() + entry = { + "agent_id": agent_id, + "last_run_id": run_id, + "cwd": cwd, + "task_summary": task_summary, + "status": status, + "result_preview": result_preview[:500], + "updated_at": datetime.now(timezone.utc).isoformat(), + } + sessions: list[dict[str, Any]] = data.setdefault("sessions", []) + sessions = [s for s in sessions if s.get("agent_id") != agent_id] + sessions.insert(0, entry) + data["sessions"] = sessions[:20] + _path().write_text(json.dumps(data, indent=2)) + return entry + + +def list_sessions(limit: int = 10) -> list[dict[str, Any]]: + return load_sessions().get("sessions", [])[:limit] + + +def get_session(agent_id: str) -> dict[str, Any] | None: + for entry in load_sessions().get("sessions", []): + if entry.get("agent_id") == agent_id: + return entry + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d47359c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for cursor-mcp.""" diff --git a/tests/test_cursor_mcp.py b/tests/test_cursor_mcp.py new file mode 100644 index 0000000..e0870bc --- /dev/null +++ b/tests/test_cursor_mcp.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from cursor_mcp.errors import CursorMCPError +from cursor_mcp.repo import validate_git_repo +from cursor_mcp.sessions import get_session, save_session + + +class RepoValidationTests(unittest.TestCase): + def test_requires_git_repo_by_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + with self.assertRaises(CursorMCPError) as ctx: + validate_git_repo(workdir, required=True) + self.assertIn("not a git repository", str(ctx.exception)) + + def test_accepts_git_repo(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + subprocess.run(["git", "init"], cwd=workdir, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init"], + cwd=workdir, + check=True, + capture_output=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.com", + }, + ) + info = validate_git_repo(workdir, required=True) + self.assertIsNotNone(info) + assert info is not None + self.assertIn(info["branch"], {"main", "master"}) + self.assertFalse(info["dirty"]) + + def test_optional_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertIsNone(validate_git_repo(Path(tmp), required=False)) + + +class SessionTests(unittest.TestCase): + def test_get_session_returns_saved_cwd(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "sessions.json" + from cursor_mcp import config + + original = config.settings.sessions_path + config.settings.sessions_path = str(path) + try: + save_session( + agent_id="agent-123", + run_id="run-1", + cwd="/tmp/project-a", + task_summary="first task", + status="finished", + result_preview="done", + ) + entry = get_session("agent-123") + self.assertIsNotNone(entry) + assert entry is not None + self.assertEqual(entry["cwd"], "/tmp/project-a") + finally: + config.settings.sessions_path = original + + +if __name__ == "__main__": + unittest.main()