"""Cursor Agent CLI transport helpers.
This module provides Cursor-specific MCP transport helpers.
Research-confirmed facts (Cursor Agent CLI ``agent``):
* Executable: ``agent`` (binary name on ``PATH``)
* Headless flag: ``--print`` with ``--output-format stream-json``
* Autonomy flag: ``--yolo`` (or ``--auto-review`` when configured)
* MCP config path: workspace ``.cursor/mcp.json`` AND user-global
``~/.cursor/mcp.json`` (Cursor may prefer one over the other
depending on cwd; writing both ensures MCP is wired for any
invocation pattern)
* HTTP JSON key: ``url`` (Cursor's documented MCP server shape)
* Output format: NDJSON ``stream-json`` (parsed by ``CursorParser``)
Cursor's MCP server configuration uses the standard MCP convention::
{
"mcpServers": {
"ralph": {
"url": "http://127.0.0.1:<port>/mcp"
}
}
}
Ralph reads existing Cursor upstream servers from the workspace-local
``.cursor/mcp.json`` and the user-global ``~/.cursor/mcp.json`` files,
merges the run-scoped ``ralph`` entry through the existing upstream
merge flow (``merge_existing_upstreams``), and writes the merged
config to BOTH paths so the agent picks up MCP regardless of the cwd
it was launched from.
The write/restore protocol mirrors the AGY pattern in
``ralph/mcp/transport/agy.py``: a process-local ``threading.Lock``
serialises concurrent cursors, an atomic ``Path.replace`` keeps the
write torn-write-safe, and the original-bytes restore happens INSIDE
the critical section so a parallel sibling cannot interleave its own
write/restore between our read and our restore.
"""
from __future__ import annotations
import json
import threading
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from pathlib import Path
from typing import cast
from ralph.mcp.tools.names import RALPH_MCP_SERVER_NAME
from ralph.mcp.transport.common import _load_mcpservers_from_paths, merge_existing_upstreams
from ralph.mcp.upstream.config import UpstreamMcpServer, normalize_upstream_mcp_servers
# Process-local lock that serialises concurrent invocations of
# :func:`cursor_workspace_mcp_endpoint` so two sibling Cursor sessions
# cannot interleave their read/write/restore steps on the global MCP
# config file. See the context manager's docstring for the full
# concurrency contract.
_cursor_mcp_lock = threading.Lock()
def _cursor_global_config_path() -> Path:
"""Return Cursor's global MCP config path.
The documented Cursor MCP config surface is ``~/.cursor/mcp.json``.
"""
return Path.home() / ".cursor" / "mcp.json"
def _cursor_workspace_config_path(workspace_path: Path) -> Path:
"""Return the workspace-local Cursor MCP config path.
The documented workspace-local Cursor MCP config surface is
``.cursor/mcp.json`` (relative to the workspace root).
"""
return workspace_path / ".cursor" / "mcp.json"
[docs]
def cursor_mcp_config(endpoint: str) -> str:
"""Return the Cursor MCP JSON config string pointing to the given endpoint.
Args:
endpoint: The MCP server HTTP endpoint URL.
Returns:
JSON string with ``mcpServers`` containing the Ralph entry with
the ``url`` key (Cursor's documented MCP server shape).
"""
config_payload = {
"mcpServers": {
RALPH_MCP_SERVER_NAME: {
"url": endpoint,
}
}
}
return json.dumps(config_payload, separators=(",", ":"))
def _cursor_paths_to_consider(
workspace_path: Path | None,
) -> tuple[Path, ...]:
"""Return the list of Cursor MCP config paths to consider.
Order: workspace-local ``.cursor/mcp.json`` first (when ``workspace_path``
is provided), then the user-global ``~/.cursor/mcp.json`` (always).
"""
workspace_paths: tuple[Path, ...] = ()
if workspace_path is not None:
workspace_paths = (_cursor_workspace_config_path(workspace_path),)
return (
*workspace_paths,
_cursor_global_config_path(),
)
[docs]
@contextmanager
def cursor_workspace_mcp_endpoint(
workspace_path: Path, endpoint: str, *, unsafe_mode: bool = False
) -> Iterator[None]:
"""Write a run-scoped Ralph MCP config to Cursor's paths and restore them on exit.
Writes the merged config (Ralph entry + merged upstream servers in
``unsafe_mode``) to BOTH the workspace-local ``.cursor/mcp.json`` and
the user-global ``~/.cursor/mcp.json`` so a Cursor invocation launched
from inside or outside the workspace picks up the run-scoped Ralph MCP
endpoint. On exit the original bytes are restored on each path that
was modified.
Concurrency safety: this context manager serialises concurrent callers
with a single :class:`threading.Lock` (process-local) and writes the
merged config atomically (via ``Path.replace``) so a parallel Cursor
session cannot observe a torn write or clobber a sibling session's
restore step. The original-bytes read happens INSIDE the critical
section so a parallel sibling cannot interleave its own write/restore
between our read and our restore.
"""
_cursor_mcp_lock.acquire()
try:
# Snapshot the original bytes BEFORE we write so the restore step
# can put each path back exactly as we found it (including the
# missing-file case for paths that did not exist).
original_by_path: dict[Path, bytes | None] = {}
for config_path in _cursor_paths_to_consider(workspace_path):
original_by_path[config_path] = (
config_path.read_bytes() if config_path.is_file() else None
)
current_config: dict[str, object] = {
"mcpServers": {RALPH_MCP_SERVER_NAME: {"url": endpoint}},
"workspace_path": workspace_path,
}
merged_config = merge_existing_upstreams(
"cursor", current_config, unsafe_mode=unsafe_mode, workspace_path=workspace_path
)
try:
for config_path in _cursor_paths_to_consider(workspace_path):
config_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: stage to a sibling temp file then
# ``Path.replace`` so a concurrent reader (Cursor's Go
# runtime) never sees a half-written config. The temp
# file is unique per call to avoid a TOCTOU between two
# siblings staging the same name.
_stage_path = config_path.with_suffix(
config_path.suffix + f".ralph-staging.{id(merged_config)}"
)
_stage_path.write_text(json.dumps(merged_config, indent=2), encoding="utf-8")
_stage_path.replace(config_path)
yield
finally:
for config_path in _cursor_paths_to_consider(workspace_path):
original_bytes = original_by_path.get(config_path)
if original_bytes is None:
if config_path.is_file():
config_path.unlink()
else:
# Atomic restore via the same staging pattern so a
# concurrent sibling that staged its own write
# between our yield and our restore does not lose
# its bytes.
_restore_path = config_path.with_suffix(
config_path.suffix + f".ralph-restore.{id(original_bytes)}"
)
_restore_path.write_bytes(original_bytes)
_restore_path.replace(config_path)
finally:
_cursor_mcp_lock.release()
def _normalize_cursor_server_entry(name: str, entry: object) -> tuple[str, object] | None:
"""Normalize a Cursor server entry to Ralph's expected format.
Cursor's MCP server shape uses ``url`` for HTTP servers, which is
the standard Ralph normalizer's expected key. This helper is the
identity mapping for cursor (kept as a normalizer hook for parity
with the agy / claude / nanocoder helpers, and as the documented
extension point if a future Cursor release uses a different key).
Args:
name: Server name.
entry: Raw server entry dict from ``mcpServers``.
Returns:
Tuple of ``(name, normalized_entry)`` if valid, ``None`` if
skipped.
"""
if name == RALPH_MCP_SERVER_NAME:
return None
if not isinstance(entry, Mapping):
return None
return name, cast("dict[str, object]", entry)
[docs]
def load_existing_cursor_upstream_servers(
workspace_path: Path | None = None,
) -> tuple[UpstreamMcpServer, ...]:
"""Read Cursor's MCP config files and return any upstream MCP servers found.
Args:
workspace_path: Optional workspace path for the workspace-local
``.cursor/mcp.json``.
Returns:
Tuple of :class:`UpstreamMcpServer` objects found in Cursor
config files. The Ralph entry is filtered out so it does not
collide with the run-scoped ``ralph`` injection.
"""
return normalize_upstream_mcp_servers(
_load_mcpservers_from_paths(
_cursor_paths_to_consider(workspace_path),
_normalize_cursor_server_entry,
)
)
__all__ = [
"cursor_mcp_config",
"cursor_workspace_mcp_endpoint",
"load_existing_cursor_upstream_servers",
]