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 <cursoragent@cursor.com>
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -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/
|
||||
18
CHANGELOG.md
Normal file
18
CHANGELOG.md
Normal file
@@ -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.
|
||||
104
README.md
Normal file
104
README.md
Normal file
@@ -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
|
||||
104
docs/CONFIGURATION.md
Normal file
104
docs/CONFIGURATION.md
Normal file
@@ -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`.
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -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"]
|
||||
1
src/cursor_mcp/__init__.py
Normal file
1
src/cursor_mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "1.1.0"
|
||||
22
src/cursor_mcp/audit.py
Normal file
22
src/cursor_mcp/audit.py
Normal file
@@ -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")
|
||||
310
src/cursor_mcp/client.py
Normal file
310
src/cursor_mcp/client.py
Normal file
@@ -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(),
|
||||
}
|
||||
22
src/cursor_mcp/config.py
Normal file
22
src/cursor_mcp/config.py
Normal file
@@ -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()
|
||||
5
src/cursor_mcp/errors.py
Normal file
5
src/cursor_mcp/errors.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Shared cursor-mcp exceptions."""
|
||||
|
||||
|
||||
class CursorMCPError(Exception):
|
||||
pass
|
||||
114
src/cursor_mcp/progress.py
Normal file
114
src/cursor_mcp/progress.py
Normal file
@@ -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)
|
||||
57
src/cursor_mcp/repo.py
Normal file
57
src/cursor_mcp/repo.py
Normal file
@@ -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
|
||||
150
src/cursor_mcp/server.py
Normal file
150
src/cursor_mcp/server.py
Normal file
@@ -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()
|
||||
64
src/cursor_mcp/sessions.py
Normal file
64
src/cursor_mcp/sessions.py
Normal file
@@ -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
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for cursor-mcp."""
|
||||
76
tests/test_cursor_mcp.py
Normal file
76
tests/test_cursor_mcp.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user