From d754041dfbcfe8dbac61d69308d28051b49399fd Mon Sep 17 00:00:00 2001 From: 10^8byte <3149579303@qq.com> Date: Thu, 23 Apr 2026 09:53:07 +0800 Subject: [PATCH 1/5] fix(policy): handle OSError from is_file() on macOS PATH_MAX limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When load_policy() receives a YAML string that exceeds ~1023 bytes, the is_file() syscall fails on macOS (PATH_MAX ≈ 1024) with OSError [Errno 63] File name too long. Wrap the call in try/except to gracefully fall back to treating the input as a YAML string. Fixes #848 --- src/apm_cli/policy/parser.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/policy/parser.py b/src/apm_cli/policy/parser.py index 16a26bc02..bbf375743 100644 --- a/src/apm_cli/policy/parser.py +++ b/src/apm_cli/policy/parser.py @@ -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: From d642f8efa96c5bcec32abfd6bfab6f58643709c8 Mon Sep 17 00:00:00 2001 From: 10^8byte <3149579303@qq.com> Date: Thu, 23 Apr 2026 17:24:24 +0800 Subject: [PATCH 2/5] fix: CursorClientAdapter emits invalid type=local in .cursor/mcp.json - Override _format_server_config() to emit correct 'type' field: - stdio transports -> 'type: stdio' (not 'type: local') - http/sse transports -> 'type: http' - Remove Copilot-specific 'tools' and 'id' fields that cause Cursor's MCP loader to silently reject servers Fixes #844 --- src/apm_cli/adapters/client/cursor.py | 129 +++++++++++++++++++++++++- tests/unit/test_cursor_mcp.py | 77 +++++++++++++++ 2 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/apm_cli/adapters/client/cursor.py b/src/apm_cli/adapters/client/cursor.py index 92462ec0f..30a7a1ac5 100644 --- a/src/apm_cli/adapters/client/cursor.py +++ b/src/apm_cli/adapters/client/cursor.py @@ -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 @@ -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 diff --git a/tests/unit/test_cursor_mcp.py b/tests/unit/test_cursor_mcp.py index 153253c11..72ee9331e 100644 --- a/tests/unit/test_cursor_mcp.py +++ b/tests/unit/test_cursor_mcp.py @@ -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.""" From 1fe66ab4f03683b69aa1fa91d02b806727fbc9cd Mon Sep 17 00:00:00 2001 From: 10^8byte <3149579303@qq.com> Date: Sun, 26 Apr 2026 00:25:39 +0800 Subject: [PATCH 3/5] fix(cursor): refactor _format_server_config to super()+transform pattern Cursor adapter previously forked ~106 lines of Copilot's _format_server_config and silently dropped GitHub MCP auth, header env-var resolution, _warn_input_variables, and _apm_tools_override -- recreating the same class of silent-failure bug as #844. - Replace copy-paste fork with super() delegate-then-transform (~20 lines) - Restore GitHub Bearer token injection for github-mcp-server remotes - Restore header env-var resolution via _resolve_env_variable - Restore _warn_input_variables for \ references - Normalize sse/streamable-http transport types to 'http' for Cursor - Strip Copilot-only 'tools' and 'id' fields that cause Cursor to reject - Update docstrings to document delegate-then-transform pattern - Add Optional[dict] type hints to _format_server_config - Fix non-ASCII em dashes -> ASCII '--' - Catch (OSError, ValueError) in parser.py load_policy() for invalid paths - Add 7 new unit tests (GitHub auth, SSE normalization, edge cases) Fixes #844 Fixes #848 --- CHANGELOG.md | 4 + src/apm_cli/adapters/client/cursor.py | 166 +++++++------------------- src/apm_cli/policy/parser.py | 2 +- tests/unit/policy/test_parser.py | 26 ++++ tests/unit/test_cursor_mcp.py | 108 +++++++++++++++++ uv.lock | 2 +- 6 files changed, 184 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d8ef985..373923e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Cursor adapter: refactor `_format_server_config` from a ~100-line copy-paste fork to a `super()` + transform pattern (~15 lines). This restores GitHub MCP auto-auth, header env-var resolution, `_warn_input_variables`, and `_apm_tools_override` that were silently dropped in the initial Cursor integration. Fixes #844. +- Cursor adapter: update module and class docstrings to document the delegate-then-transform pattern (matching `OpenCodeClientAdapter`), and replace non-ASCII em dashes with ASCII. +- Policy parser: catch `ValueError` alongside `OSError` in `load_policy()` `Path.is_file()` guard to tolerate invalid path strings (e.g. embedded NUL bytes) without crashing. Closes #848. + - CI docs: clarify that branch-protection ruleset must store the check-run name (`gate`), not the workflow display string (`Merge Gate / gate`); document the merge-gate aggregator in `cicd.instructions.md` and mark the legacy stub workflow as deprecated. ### Removed diff --git a/src/apm_cli/adapters/client/cursor.py b/src/apm_cli/adapters/client/cursor.py index 30a7a1ac5..45cbbeb04 100644 --- a/src/apm_cli/adapters/client/cursor.py +++ b/src/apm_cli/adapters/client/cursor.py @@ -1,17 +1,22 @@ """Cursor IDE implementation of MCP client adapter. Cursor uses the standard ``mcpServers`` JSON format at ``.cursor/mcp.json`` -(repo-local). The config schema is identical to GitHub Copilot CLI, so this -adapter subclasses :class:`CopilotClientAdapter` and only overrides the -config-path logic and the user-facing labels. +(repo-local). Cursor's schema differs from Copilot CLI in key ways: + +- ``type`` must be ``"stdio"`` or ``"http"`` (not ``"local"``). +- ``tools`` and ``id`` fields are not supported. + +This adapter delegates config formatting to :class:`CopilotClientAdapter` +and then transforms the result for Cursor compatibility. APM only writes to ``.cursor/mcp.json`` when the ``.cursor/`` directory -already exists — Cursor support is opt-in. +already exists -- Cursor support is opt-in. """ import json import os from pathlib import Path +from typing import Optional from .copilot import CopilotClientAdapter @@ -19,20 +24,15 @@ class CursorClientAdapter(CopilotClientAdapter): """Cursor IDE MCP client adapter. - 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:: + Inherits config path and read/write logic from :class:`CopilotClientAdapter` + and uses the delegate-then-transform pattern for ``_format_server_config``: - 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. + - Calls ``super()._format_server_config()`` to get the fully-resolved config + (GitHub auth, header env-var resolution, ``_warn_input_variables``, etc.) + - Translates Cursor-incompatible fields: + - ``type: "local"`` becomes ``"stdio"`` (Cursor schema) + - ``sse/streamable-http`` transports normalized to ``"http"`` + - Copilot-specific ``tools`` and ``id`` fields stripped """ supports_user_scope: bool = False @@ -45,7 +45,7 @@ def get_config_path(self): """Return the path to ``.cursor/mcp.json`` in the repository root. Unlike the Copilot adapter this is a **repo-local** path. The - ``.cursor/`` directory is *not* created automatically — APM only + ``.cursor/`` directory is *not* created automatically -- APM only writes here when the directory already exists. """ cursor_dir = Path(os.getcwd()) / ".cursor" @@ -150,112 +150,34 @@ def configure_mcp_server( return False # ------------------------------------------------------------------ # - # _format_server_config — MUST override; do NOT silently inherit Copilot + # _format_server_config — delegate to parent, then transform for Cursor # ------------------------------------------------------------------ # - 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. + def _format_server_config( + self, + server_info: dict, + env_overrides: Optional[dict] = None, + runtime_vars: Optional[dict] = None, + ) -> dict: + """Format server config via parent, then adapt for Cursor schema. + + Cursor requires ``type: "stdio"`` (not ``"local"``) and does not + support the Copilot-specific ``tools`` and ``id`` fields. """ - 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 + config = super()._format_server_config(server_info, env_overrides, runtime_vars) + + # Adapt type field for Cursor compatibility + if config.get("type") == "local": + config["type"] = "stdio" + elif config.get("type") == "http": + # For remote endpoints, ensure type remains http (as opposed to sse, etc.) + # and normalize sse/streamable-http to http for Cursor + transport_type = server_info.get("remotes", [{}])[0].get("transport_type", "") + if transport_type in ("sse", "streamable-http"): + config["type"] = "http" + + # Remove Copilot-only fields that cause Cursor's MCP loader to silently reject servers + config.pop("tools", None) + config.pop("id", None) return config diff --git a/src/apm_cli/policy/parser.py b/src/apm_cli/policy/parser.py index bbf375743..524b1d415 100644 --- a/src/apm_cli/policy/parser.py +++ b/src/apm_cli/policy/parser.py @@ -242,7 +242,7 @@ def load_policy(source: Union[str, Path]) -> Tuple[ApmPolicy, List[str]]: try: is_file = path.is_file() - except OSError: + except (OSError, ValueError): is_file = False if is_file: diff --git a/tests/unit/policy/test_parser.py b/tests/unit/policy/test_parser.py index cf834bfb3..d536f0602 100644 --- a/tests/unit/policy/test_parser.py +++ b/tests/unit/policy/test_parser.py @@ -322,5 +322,31 @@ def test_load_from_pathlib_path(self): os.unlink(str(path)) +class TestLoadPolicyYamlStringEdgeCases(unittest.TestCase): + """Test load_policy with YAML string inputs that could trigger OS errors.""" + + def test_long_yaml_string_treated_as_string_not_path(self): + """A YAML string longer than PATH_MAX should not crash with OSError.""" + yaml_str = "name: " + "x" * 2000 + "\nversion: '1.0'" + policy, warnings = load_policy(yaml_str) + # The name will be truncated by YAML parsing but shouldn't crash + self.assertIsInstance(policy, ApmPolicy) + + def test_yaml_string_with_null_bytes_raises_gracefully(self): + """A YAML string with embedded null bytes should be treated as string.""" + # Windows may raise ValueError for paths with null bytes + yaml_str = "name: test\nversion: '0.1'" + policy, warnings = load_policy(yaml_str) + self.assertIsInstance(policy, ApmPolicy) + self.assertEqual(policy.name, "test") + + def test_yaml_string_with_special_characters(self): + """YAML strings with special characters should parse correctly.""" + yaml_str = "name: special-chars-@#$%\nversion: '1.0'\nenforcement: warn" + policy, warnings = load_policy(yaml_str) + self.assertEqual(policy.name, "special-chars-@#$%") + self.assertEqual(policy.enforcement, "warn") + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_cursor_mcp.py b/tests/unit/test_cursor_mcp.py index 72ee9331e..7b6704d54 100644 --- a/tests/unit/test_cursor_mcp.py +++ b/tests/unit/test_cursor_mcp.py @@ -197,6 +197,114 @@ def test_stdio_with_packages_outputs_type_stdio(self, mock_find): self.assertNotIn("tools", data["mcpServers"]["npm-pkg"]) self.assertNotIn("id", data["mcpServers"]["npm-pkg"]) + @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") + @patch("apm_cli.adapters.client.copilot.GitHubTokenManager") + def test_github_remote_server_gets_auth_header(self, mock_token_mgr, mock_find): + """GitHub MCP servers must receive Bearer token auth in Cursor config.""" + mock_token_mgr.return_value.get_token_for_purpose.return_value = "ghp_test_token_12345" + mock_find.return_value = { + "id": "github-uuid", + "name": "github-mcp-server", + "packages": [], + "remotes": [ + { + "url": "https://api.github.com/mcp", + "transport_type": "http", + "headers": [], + } + ], + } + ok = self.adapter.configure_mcp_server("github-mcp-server", "gh-mcp") + self.assertTrue(ok) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + server_cfg = data["mcpServers"]["gh-mcp"] + self.assertEqual(server_cfg["type"], "http") + self.assertIn("Authorization", server_cfg["headers"]) + self.assertEqual(server_cfg["headers"]["Authorization"], "Bearer ghp_test_token_12345") + self.assertNotIn("tools", server_cfg) + self.assertNotIn("id", server_cfg) + + @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") + def test_sse_transport_normalized_to_http(self, mock_find): + """SSE transport type must be normalized to 'http' for Cursor.""" + mock_find.return_value = { + "id": "sse-uuid", + "name": "sse-srv", + "packages": [], + "remotes": [ + { + "url": "https://example.com/sse", + "transport_type": "sse", + "headers": [{"name": "X-API-Key", "value": "test-key"}], + } + ], + } + ok = self.adapter.configure_mcp_server("sse-srv", "sse-mcp") + self.assertTrue(ok) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + self.assertEqual(data["mcpServers"]["sse-mcp"]["type"], "http") + self.assertIn("X-API-Key", data["mcpServers"]["sse-mcp"]["headers"]) + + @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") + def test_streamable_http_transport_normalized_to_http(self, mock_find): + """streamable-http transport type must be normalized to 'http' for Cursor.""" + mock_find.return_value = { + "id": "stream-uuid", + "name": "stream-srv", + "packages": [], + "remotes": [ + { + "url": "https://example.com/mcp", + "transport_type": "streamable-http", + "headers": [], + } + ], + } + ok = self.adapter.configure_mcp_server("stream-srv", "stream-mcp") + self.assertTrue(ok) + data = json.loads(self.mcp_json.read_text(encoding="utf-8")) + self.assertEqual(data["mcpServers"]["stream-mcp"]["type"], "http") + + def test_stdio_warns_on_input_variables(self): + """_warn_input_variables should be called for ${input:...} in env vars.""" + server_info = { + "name": "my-cli", + "_raw_stdio": { + "command": "./my-cli", + "args": ["mcp"], + "env": {"API_TOKEN": "${input:api-token}"}, + }, + } + # Should not raise; the warning is printed to stdout + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["type"], "stdio") + self.assertIn("env", config) + + @patch("apm_cli.adapters.client.copilot.GitHubTokenManager") + def test_format_server_config_delegates_to_parent(self, mock_token_mgr): + """Verify that _format_server_config calls parent and transforms result.""" + mock_token_mgr.return_value.get_token_for_purpose.return_value = None + server_info = { + "id": "test-id", + "name": "github-mcp-server", + "packages": [], + "remotes": [ + { + "url": "https://api.github.com/mcp", + "transport_type": "http", + "headers": [{"name": "X-Custom", "value": "val"}], + } + ], + } + config = self.adapter._format_server_config(server_info) + # Parent resolves headers, so X-Custom should be present + self.assertIn("headers", config) + self.assertIn("X-Custom", config["headers"]) + # Cursor-specific transformations applied + self.assertEqual(config["type"], "http") + self.assertNotIn("tools", config) + self.assertNotIn("id", config) + class TestMCPIntegratorCursorStaleCleanup(unittest.TestCase): """remove_stale() cleans .cursor/mcp.json.""" diff --git a/uv.lock b/uv.lock index b28996d6e..6676a6980 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.9.1" +version = "0.9.2" source = { editable = "." } dependencies = [ { name = "click" }, From 684a669c29b9c3c151ab375cbe46f3e5a2e10f5d Mon Sep 17 00:00:00 2001 From: 10^8byte <3149579303@qq.com> Date: Tue, 28 Apr 2026 22:16:09 +0800 Subject: [PATCH 4/5] fix(cursor): refactor _format_server_config to super()+transform pattern Cursor adapter previously forked ~106 lines of Copilot's _format_server_config and silently dropped GitHub MCP auth, header env-var resolution, _warn_input_variables, and _apm_tools_override -- recreating the same class of silent-failure bug as #844. - Replace copy-paste fork with super() delegate-then-transform (~20 lines) - Restore GitHub Bearer token injection for github-mcp-server remotes - Security: replace literal tokens with \ references so secrets never touch disk in .cursor/mcp.json - Restore header env-var resolution via _resolve_env_variable - Restore _warn_input_variables for \ references - Normalize sse/streamable-http transport types to 'http' for Cursor - Strip Copilot-only 'tools' and 'id' fields that cause Cursor to reject - Update docstrings to document delegate-then-transform pattern - Add Optional[dict] type hints to _format_server_config - Fix non-ASCII em dashes -> ASCII '--' - Catch (OSError, ValueError) in parser.py load_policy() for invalid paths - Add 7 new unit tests (GitHub auth, SSE normalization, edge cases) Fixes #844 Fixes #848 --- .gitignore | 1 + src/apm_cli/adapters/client/cursor.py | 40 +++++++++++++++------------ tests/unit/test_cursor_mcp.py | 24 ++++++++++++---- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 8dd1c34a4..1cbddca1a 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,6 @@ apm_modules/ build/tmp/ scout-pipeline-result.png .copilot/ +.cursor/ .playwright-mcp/ server.pid diff --git a/src/apm_cli/adapters/client/cursor.py b/src/apm_cli/adapters/client/cursor.py index 45cbbeb04..08d980fb6 100644 --- a/src/apm_cli/adapters/client/cursor.py +++ b/src/apm_cli/adapters/client/cursor.py @@ -52,7 +52,7 @@ def get_config_path(self): return str(cursor_dir / "mcp.json") # ------------------------------------------------------------------ # - # Config read / write — override to avoid auto-creating the directory + # Config read / write -- override to avoid auto-creating the directory # ------------------------------------------------------------------ # def update_config(self, config_updates): @@ -90,7 +90,7 @@ def get_current_config(self): return {} # ------------------------------------------------------------------ # - # configure_mcp_server — thin override for the print label + # configure_mcp_server -- thin override for the print label # ------------------------------------------------------------------ # def configure_mcp_server( @@ -140,9 +140,7 @@ def configure_mcp_server( ) self.update_config({config_key: server_config}) - print( - f"Successfully configured MCP server '{config_key}' for Cursor" - ) + print(f"Successfully configured MCP server '{config_key}' for Cursor") return True except Exception as e: @@ -150,7 +148,7 @@ def configure_mcp_server( return False # ------------------------------------------------------------------ # - # _format_server_config — delegate to parent, then transform for Cursor + # _format_server_config -- delegate to parent, then transform for Cursor # ------------------------------------------------------------------ # def _format_server_config( @@ -163,21 +161,27 @@ def _format_server_config( Cursor requires ``type: "stdio"`` (not ``"local"``) and does not support the Copilot-specific ``tools`` and ``id`` fields. + + Security: ``.cursor/mcp.json`` is repo-local and may be committed to + version control. For GitHub MCP servers the parent injects a literal + ``Bearer `` header; this override replaces it with a + ``${env:GITHUB_TOKEN}`` reference so the secret never touches disk. + Cursor resolves ``${env:...}`` from the process environment at runtime. """ config = super()._format_server_config(server_info, env_overrides, runtime_vars) - - # Adapt type field for Cursor compatibility - if config.get("type") == "local": - config["type"] = "stdio" - elif config.get("type") == "http": - # For remote endpoints, ensure type remains http (as opposed to sse, etc.) - # and normalize sse/streamable-http to http for Cursor - transport_type = server_info.get("remotes", [{}])[0].get("transport_type", "") - if transport_type in ("sse", "streamable-http"): - config["type"] = "http" - - # Remove Copilot-only fields that cause Cursor's MCP loader to silently reject servers config.pop("tools", None) config.pop("id", None) + if config.get("type") == "local": + config["type"] = "stdio" + + # Security: for GitHub MCP servers, replace the literal Bearer token + # that the parent injected with an env-var reference. This mirrors + # the VS Code adapter's approach of not persisting secrets to disk. + remote = (server_info.get("remotes") or [{}])[0] + if self._is_github_server(server_info.get("name", ""), remote.get("url", "")): + headers = config.get("headers", {}) + auth = headers.get("Authorization", "") + if auth.startswith("Bearer ") and not auth.startswith("Bearer ${"): + headers["Authorization"] = "Bearer ${env:GITHUB_TOKEN}" return config diff --git a/tests/unit/test_cursor_mcp.py b/tests/unit/test_cursor_mcp.py index 7b6704d54..117f71077 100644 --- a/tests/unit/test_cursor_mcp.py +++ b/tests/unit/test_cursor_mcp.py @@ -64,7 +64,9 @@ def test_get_current_config_existing_file(self): # -- update_config -- def test_update_config_creates_file(self): - self.adapter.update_config({"my-server": {"command": "npx", "args": ["-y", "pkg"]}}) + self.adapter.update_config( + {"my-server": {"command": "npx", "args": ["-y", "pkg"]}} + ) data = json.loads(self.mcp_json.read_text(encoding="utf-8")) self.assertEqual(data["mcpServers"]["my-server"]["command"], "npx") @@ -200,8 +202,15 @@ def test_stdio_with_packages_outputs_type_stdio(self, mock_find): @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") @patch("apm_cli.adapters.client.copilot.GitHubTokenManager") def test_github_remote_server_gets_auth_header(self, mock_token_mgr, mock_find): - """GitHub MCP servers must receive Bearer token auth in Cursor config.""" - mock_token_mgr.return_value.get_token_for_purpose.return_value = "ghp_test_token_12345" + """GitHub MCP servers must use env-var reference for auth in Cursor config. + + Security: Cursor stores config in .cursor/mcp.json which may be committed + to git. The adapter replaces literal tokens with ${env:GITHUB_TOKEN} + references so secrets never touch disk. + """ + mock_token_mgr.return_value.get_token_for_purpose.return_value = ( + "ghp_test_token_12345" + ) mock_find.return_value = { "id": "github-uuid", "name": "github-mcp-server", @@ -220,7 +229,10 @@ def test_github_remote_server_gets_auth_header(self, mock_token_mgr, mock_find): server_cfg = data["mcpServers"]["gh-mcp"] self.assertEqual(server_cfg["type"], "http") self.assertIn("Authorization", server_cfg["headers"]) - self.assertEqual(server_cfg["headers"]["Authorization"], "Bearer ghp_test_token_12345") + # Security: token must be env-var reference, not literal + self.assertEqual( + server_cfg["headers"]["Authorization"], "Bearer ${env:GITHUB_TOKEN}" + ) self.assertNotIn("tools", server_cfg) self.assertNotIn("id", server_cfg) @@ -329,7 +341,9 @@ def test_remove_stale_cursor(self): from apm_cli.integration.mcp_integrator import MCPIntegrator self.mcp_json.write_text( - json.dumps({"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}}), + json.dumps( + {"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}} + ), encoding="utf-8", ) MCPIntegrator.remove_stale({"stale"}, runtime="cursor") From 4ff32e4737ecf2c363107d5ec7b27c00f03cf21d Mon Sep 17 00:00:00 2001 From: 10^8byte <3149579303@qq.com> Date: Wed, 29 Apr 2026 16:14:04 +0800 Subject: [PATCH 5/5] Optimize adjustments according to the robot's suggestions and fix blocking issues --- CHANGELOG.md | 2 +- src/apm_cli/adapters/client/base.py | 2 +- src/apm_cli/adapters/client/copilot.py | 5 ++- src/apm_cli/adapters/client/cursor.py | 46 +++++++++++++++----- src/apm_cli/commands/compile/cli.py | 20 ++++++--- src/apm_cli/install/phases/targets.py | 7 ++- tests/unit/test_cursor_mcp.py | 59 ++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6792f6a99..4dd2630e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Cursor adapter: refactor `_format_server_config` from a ~100-line copy-paste fork to a `super()` + transform pattern (~15 lines). This restores GitHub MCP auto-auth, header env-var resolution, `_warn_input_variables`, and `_apm_tools_override` that were silently dropped in the initial Cursor integration. Fixes #844. +- Cursor adapter: refactor `_format_server_config` from a ~100-line copy-paste fork to a `super()` + transform pattern (~15 lines). This restores header env-var resolution, `_warn_input_variables`, and `_apm_tools_override` that were silently dropped in the initial Cursor integration. For security, resolved Bearer tokens are replaced with `${env:GITHUB_TOKEN}` references so credentials never touch the repo-local `.cursor/mcp.json` file. Fixes #844. - Cursor adapter: update module and class docstrings to document the delegate-then-transform pattern (matching `OpenCodeClientAdapter`), and replace non-ASCII em dashes with ASCII. - Policy parser: catch `ValueError` alongside `OSError` in `load_policy()` `Path.is_file()` guard to tolerate invalid path strings (e.g. embedded NUL bytes) without crashing. Closes #848. - Rename `DownloadStrategyManager` to `DownloadDelegate` to better reflect Facade/Delegate pattern (#918) diff --git a/src/apm_cli/adapters/client/base.py b/src/apm_cli/adapters/client/base.py index d5fae39d2..7063e646f 100644 --- a/src/apm_cli/adapters/client/base.py +++ b/src/apm_cli/adapters/client/base.py @@ -120,6 +120,6 @@ def _warn_input_variables(mapping, server_name, runtime_label): seen.add(var_id) print( f"[!] Warning: ${{input:{var_id}}} in server " - f"'{server_name}' will not be resolved \u2014 " + f"'{server_name}' will not be resolved -- " f"{runtime_label} does not support input variable prompts" ) diff --git a/src/apm_cli/adapters/client/copilot.py b/src/apm_cli/adapters/client/copilot.py index 9c1077c9f..c93dae9a7 100644 --- a/src/apm_cli/adapters/client/copilot.py +++ b/src/apm_cli/adapters/client/copilot.py @@ -25,6 +25,7 @@ class CopilotClientAdapter(MCPClientAdapter): """ supports_user_scope: bool = True + _runtime_label: str = "Copilot CLI" def __init__(self, registry_url=None): """Initialize the Copilot CLI client adapter. @@ -176,7 +177,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No config["args"] = raw["args"] if raw.get("env"): config["env"] = raw["env"] - self._warn_input_variables(raw["env"], server_info.get("name", ""), "Copilot CLI") + self._warn_input_variables(raw["env"], server_info.get("name", ""), self._runtime_label) # Apply tools override if present tools_override = server_info.get("_apm_tools_override") if tools_override: @@ -245,7 +246,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No # Warn about unresolvable ${input:...} references in headers if config.get("headers"): - self._warn_input_variables(config["headers"], server_info.get("name", ""), "Copilot CLI") + self._warn_input_variables(config["headers"], server_info.get("name", ""), self._runtime_label) # Apply tools override from MCP dependency overlay if present tools_override = server_info.get("_apm_tools_override") diff --git a/src/apm_cli/adapters/client/cursor.py b/src/apm_cli/adapters/client/cursor.py index 08d980fb6..4a0b5a1de 100644 --- a/src/apm_cli/adapters/client/cursor.py +++ b/src/apm_cli/adapters/client/cursor.py @@ -31,11 +31,14 @@ class CursorClientAdapter(CopilotClientAdapter): (GitHub auth, header env-var resolution, ``_warn_input_variables``, etc.) - Translates Cursor-incompatible fields: - ``type: "local"`` becomes ``"stdio"`` (Cursor schema) - - ``sse/streamable-http`` transports normalized to ``"http"`` - Copilot-specific ``tools`` and ``id`` fields stripped + - Security: resolved Bearer tokens and env-var secrets are replaced with + ``${env:...}`` references so credentials never touch the repo-local + ``.cursor/mcp.json`` file. """ supports_user_scope: bool = False + _runtime_label: str = "Cursor" # ------------------------------------------------------------------ # # Config path @@ -174,14 +177,37 @@ def _format_server_config( if config.get("type") == "local": config["type"] = "stdio" - # Security: for GitHub MCP servers, replace the literal Bearer token - # that the parent injected with an env-var reference. This mirrors - # the VS Code adapter's approach of not persisting secrets to disk. - remote = (server_info.get("remotes") or [{}])[0] - if self._is_github_server(server_info.get("name", ""), remote.get("url", "")): - headers = config.get("headers", {}) - auth = headers.get("Authorization", "") - if auth.startswith("Bearer ") and not auth.startswith("Bearer ${"): - headers["Authorization"] = "Bearer ${env:GITHUB_TOKEN}" + # Security: .cursor/mcp.json is repo-local and may be committed to + # version control. Any header value that the parent resolved from + # an environment variable placeholder () must NOT be written + # as plaintext. Replace resolved Bearer tokens with env-var + # references and strip other resolved secrets entirely. + _headers = config.get("headers", {}) + if _headers: + remote = (server_info.get("remotes") or [{}])[0] + _is_gh = self._is_github_server( + server_info.get("name", ""), remote.get("url", "") + ) + _keys_to_strip = [] + for _hname, _hval in _headers.items(): + if _is_gh and _hname == "Authorization": + _auth = str(_hval) + if _auth.startswith("Bearer ") and not _auth.startswith( + "Bearer ${" + ): + _headers[_hname] = "Bearer ${env:GITHUB_TOKEN}" + elif isinstance(_hval, str) and not _hval.startswith("${"): + _raw_headers = remote.get("headers", []) if remote else [] + _was_placeholder = any( + h.get("name") == _hname + and ("<" in h.get("value", "") or "${" in h.get("value", "")) + for h in _raw_headers + ) + if _was_placeholder: + _keys_to_strip.append(_hname) + for _k in _keys_to_strip: + del _headers[_k] + if not _headers: + config.pop("headers", None) return config diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 1b559eef2..b14590864 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -386,9 +386,12 @@ def compile( apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME)) config_target = apm_pkg.target - except Exception: - # No apm.yml or parsing error - proceed with auto-detection + except FileNotFoundError: pass + except Exception as exc: + logger.warning( + f"Could not load apm.yml: {exc}. Proceeding with auto-detection." + ) # Resolve list targets to compiler-understood string compile_target = _resolve_compile_target(target) @@ -469,14 +472,17 @@ def compile( if result.success: # Handle different compilation modes if config.strategy == "distributed" and not single_agents: - # Distributed compilation results - output already shown by professional formatter - # Just show final success message if dry_run: - # Success message for dry run already included in formatter output pass else: - # Success message for actual compilation - logger.success("Compilation completed successfully!", symbol="check") + _files_written = getattr(result, "files_written", None) + if _files_written is not None and _files_written == 0: + logger.warning( + "Compilation produced no output files. " + "Check that .apm/ contains instruction or chatmode files." + ) + else: + logger.success("Compilation completed successfully!", symbol="check") else: # Traditional single-file compilation - keep existing logic diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 44f76f5ae..2a9d7b686 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -116,8 +116,8 @@ def run(ctx: "InstallContext") -> None: raise SystemExit(1) # Log target detection results + _scope_label = "global" if _is_user else "project" if ctx.logger and _targets: - _scope_label = "global" if _is_user else "project" _target_names = ", ".join( f"{t.name} (~/{t.root_dir}/)" if _is_user else t.name for t in _targets @@ -131,6 +131,11 @@ def run(ctx: "InstallContext") -> None: ctx.logger.verbose_detail( f"Lockfile: {get_lockfile_path(ctx.apm_dir)}" ) + elif ctx.logger and not _targets: + ctx.logger.warning( + f"No {_scope_label} targets resolved -- nothing will be deployed. " + f"Check 'target:' in apm.yml or use --target." + ) for _t in _targets: # When the user passes --target (or apm.yml sets target=) we honour diff --git a/tests/unit/test_cursor_mcp.py b/tests/unit/test_cursor_mcp.py index 117f71077..e92c20e47 100644 --- a/tests/unit/test_cursor_mcp.py +++ b/tests/unit/test_cursor_mcp.py @@ -317,6 +317,65 @@ def test_format_server_config_delegates_to_parent(self, mock_token_mgr): self.assertNotIn("tools", config) self.assertNotIn("id", config) + def test_tools_override_stripped_for_cursor(self): + """_apm_tools_override should be applied by parent but stripped for Cursor.""" + server_info = { + "name": "my-cli", + "_raw_stdio": {"command": "./cli", "args": ["mcp"]}, + "_apm_tools_override": ["specific-tool"], + } + config = self.adapter._format_server_config(server_info) + self.assertNotIn("tools", config) + + def test_empty_env_omitted_from_stdio_output(self): + """Empty env dict should not appear in stdio config output.""" + server_info = { + "name": "my-cli", + "_raw_stdio": { + "command": "./my-cli", + "args": ["mcp"], + }, + } + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["type"], "stdio") + self.assertNotIn("env", config) + + def test_runtime_label_is_cursor(self): + """Cursor adapter must report 'Cursor' as its runtime label.""" + self.assertEqual(self.adapter._runtime_label, "Cursor") + + def test_warn_input_variables_uses_cursor_label(self): + """_warn_input_variables should reference 'Cursor', not 'Copilot CLI'.""" + server_info = { + "name": "my-cli", + "_raw_stdio": { + "command": "./my-cli", + "args": ["mcp"], + "env": {"API_TOKEN": "${input:api-token}"}, + }, + } + import io + from unittest.mock import patch as _patch + + buf = io.StringIO() + with _patch("sys.stdout", buf): + self.adapter._format_server_config(server_info) + output = buf.getvalue() + self.assertIn("Cursor", output) + self.assertNotIn("Copilot CLI", output) + + @patch("apm_cli.registry.client.SimpleRegistryClient.find_server_by_reference") + def test_no_packages_no_remotes_returns_false(self, mock_find): + """Server with no packages and no remotes should fail gracefully.""" + mock_find.return_value = { + "id": "empty-uuid", + "name": "empty-srv", + "packages": [], + "remotes": [], + } + ok = self.adapter.configure_mcp_server("empty-srv", "empty-srv") + self.assertFalse(ok) + class TestMCPIntegratorCursorStaleCleanup(unittest.TestCase): """remove_stale() cleans .cursor/mcp.json."""