Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 125 additions & 4 deletions src/apm_cli/adapters/client/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@
class CursorClientAdapter(CopilotClientAdapter):
"""Cursor IDE MCP client adapter.

Inherits all config formatting from :class:`CopilotClientAdapter`
(``mcpServers`` JSON with ``command``/``args``/``env``). Only the
config-file location differs: repo-local ``.cursor/mcp.json`` instead
of global ``~/.copilot/mcp-config.json``.
Inherits config path and read/write logic from this class, but
**must** override :meth:`_format_server_config` because Cursor's JSON
schema differs from Copilot CLI's in two critical ways:

- ``type`` must be ``"stdio"`` or ``"http"`` (NOT ``"local"``).
- ``tools`` and ``id`` fields must **never** be emitted — they are
Copilot-CLI-specific and cause Cursor's MCP loader to silently
reject the server.

.. note::

This inheritance design is a known fragility. ``_format_server_config``
**must** be explicitly overridden in each subclass; silently inheriting
the Copilot version will produce invalid configs for the target runtime.
"""

supports_user_scope: bool = False
Expand Down Expand Up @@ -138,3 +148,114 @@ def configure_mcp_server(
except Exception as e:
print(f"Error configuring MCP server: {e}")
return False

# ------------------------------------------------------------------ #
# _format_server_config — MUST override; do NOT silently inherit Copilot
# ------------------------------------------------------------------ #

def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None):
"""Format server info into Cursor-compatible ``.cursor/mcp.json`` format.

Cursor uses ``"type": "stdio"`` or ``"type": "http"`` (NOT ``"local"``)
and does NOT support the ``tools`` or ``id`` fields that Copilot CLI uses.

Args:
server_info (dict): Server information from registry.
env_overrides (dict, optional): Pre-collected environment variable overrides.
runtime_vars (dict, optional): Pre-collected runtime variable values.

Returns:
dict: Cursor-compatible server configuration.
"""
if runtime_vars is None:
runtime_vars = {}

raw = server_info.get("_raw_stdio")
if raw:
config = {
"type": "stdio",
"command": raw["command"],
"args": raw["args"],
}
if raw.get("env"):
config["env"] = raw["env"]
self._warn_input_variables(raw["env"], server_info.get("name", ""), "Cursor")
return config

remotes = server_info.get("remotes", [])
if remotes:
remote = remotes[0]
transport = (remote.get("transport_type") or "http").strip()
if transport in ("sse", "streamable-http"):
transport = "http"
config = {
"type": "http",
"url": remote.get("url", ""),
}
headers = remote.get("headers", [])
if headers:
if isinstance(headers, list):
config["headers"] = {
h["name"]: h["value"] for h in headers if "name" in h and "value" in h
}
else:
config["headers"] = headers
return config

packages = server_info.get("packages", [])
if not packages:
raise ValueError(
f"MCP server has incomplete configuration in registry - no package "
f"information or remote endpoints available. "
f"Server: {server_info.get('name', 'unknown')}"
)

package = self._select_best_package(packages)
if not package:
raise ValueError(
f"No suitable package found for MCP server "
f"'{server_info.get('name', 'unknown')}'"
)

registry_name = self._infer_registry_name(package)
package_name = package.get("name", "")
runtime_hint = package.get("runtime_hint", "")
runtime_arguments = package.get("runtime_arguments", [])
package_arguments = package.get("package_arguments", [])
env_vars = package.get("environment_variables", [])

resolved_env = self._resolve_environment_variables(env_vars, env_overrides)
processed_runtime_args = self._process_arguments(runtime_arguments, resolved_env, runtime_vars)
processed_package_args = self._process_arguments(package_arguments, resolved_env, runtime_vars)

config = {"type": "stdio"}

if registry_name == "npm":
config["command"] = runtime_hint or "npx"
config["args"] = ["-y", package_name] + processed_runtime_args + processed_package_args
elif registry_name == "docker":
config["command"] = "docker"
if processed_runtime_args:
config["args"] = self._inject_env_vars_into_docker_args(
processed_runtime_args, resolved_env
)
else:
from ...core.docker_args import DockerArgsProcessor
config["args"] = DockerArgsProcessor.process_docker_args(
["run", "-i", "--rm", package_name],
resolved_env
)
elif registry_name == "pypi":
config["command"] = runtime_hint or "uvx"
config["args"] = [package_name] + processed_runtime_args + processed_package_args
elif registry_name == "homebrew":
config["command"] = package_name.split("/")[-1] if "/" in package_name else package_name
config["args"] = processed_runtime_args + processed_package_args
else:
config["command"] = runtime_hint or package_name
config["args"] = processed_runtime_args + processed_package_args

if resolved_env:
config["env"] = resolved_env

return config
8 changes: 6 additions & 2 deletions src/apm_cli/policy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,14 @@ def load_policy(source: Union[str, Path]) -> Tuple[ApmPolicy, List[str]]:
"""
path = Path(source) if not isinstance(source, Path) else source

if path.is_file():
try:
is_file = path.is_file()
except OSError:
is_file = False

if is_file:
raw = path.read_text(encoding="utf-8")
else:
# Treat source as a YAML string
raw = str(source)

try:
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/test_cursor_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,83 @@ def test_configure_mcp_server_skips_when_no_cursor_dir(self):
result = self.adapter.configure_mcp_server("some-server")
self.assertTrue(result)

# -- _format_server_config --

def test_stdio_server_outputs_type_stdio(self):
"""Self-defined stdio deps must emit type=stdio, not type=local."""
server_info = {
"name": "my-cli",
"_raw_stdio": {
"command": "./my-cli",
"args": ["mcp"],
"env": {"API_KEY": "secret"},
},
}
config = self.adapter._format_server_config(server_info)
self.assertEqual(config["type"], "stdio")
self.assertEqual(config["command"], "./my-cli")
self.assertEqual(config["args"], ["mcp"])
self.assertEqual(config["env"], {"API_KEY": "secret"})

def test_stdio_server_no_copilot_fields(self):
"""Cursor config must NOT emit 'tools' or 'id' fields (Copilot-specific)."""
server_info = {
"id": "registry-uuid-12345",
"name": "my-cli",
"_raw_stdio": {
"command": "./my-cli",
"args": ["mcp"],
},
}
config = self.adapter._format_server_config(server_info)
self.assertNotIn("tools", config)
self.assertNotIn("id", config)

@patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference")
def test_http_server_outputs_type_http(self, mock_find):
"""Remote servers must emit type=http, not type=local."""
mock_find.return_value = {
"id": "remote-uuid",
"name": "remote-srv",
"packages": [],
"remotes": [
{
"url": "https://example.com/mcp",
"transport_type": "http",
"headers": [{"name": "Authorization", "value": "Bearer token"}],
}
],
}
ok = self.adapter.configure_mcp_server("remote-srv", "remote-srv")
self.assertTrue(ok)
data = json.loads(self.mcp_json.read_text(encoding="utf-8"))
self.assertEqual(data["mcpServers"]["remote-srv"]["type"], "http")
self.assertNotIn("tools", data["mcpServers"]["remote-srv"])
self.assertNotIn("id", data["mcpServers"]["remote-srv"])

@patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference")
def test_stdio_with_packages_outputs_type_stdio(self, mock_find):
"""NPM/docker packages must also emit type=stdio, not type=local."""
mock_find.return_value = {
"id": "pkg-uuid",
"name": "npm-pkg",
"packages": [
{
"registry_name": "npm",
"name": "some-npm-pkg",
"runtime_hint": "npx",
"arguments": [],
"environment_variables": [],
}
],
}
ok = self.adapter.configure_mcp_server("npm-pkg", "npm-pkg")
self.assertTrue(ok)
data = json.loads(self.mcp_json.read_text(encoding="utf-8"))
self.assertEqual(data["mcpServers"]["npm-pkg"]["type"], "stdio")
self.assertNotIn("tools", data["mcpServers"]["npm-pkg"])
self.assertNotIn("id", data["mcpServers"]["npm-pkg"])


class TestMCPIntegratorCursorStaleCleanup(unittest.TestCase):
"""remove_stale() cleans .cursor/mcp.json."""
Expand Down