Skip to content

Fix/issue 844 cursor mcp json schema#869

Open
chaobo8484 wants to merge 15 commits intomicrosoft:mainfrom
chaobo8484:fix/issue-844-cursor-mcp-json-schema
Open

Fix/issue 844 cursor mcp json schema#869
chaobo8484 wants to merge 15 commits intomicrosoft:mainfrom
chaobo8484:fix/issue-844-cursor-mcp-json-schema

Conversation

@chaobo8484
Copy link
Copy Markdown
Contributor

@chaobo8484 chaobo8484 commented Apr 23, 2026

Description

Brief description of changes and motivation.

Fixes # (issue)

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

chaobo8484 and others added 3 commits April 23, 2026 09:53
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 microsoft#848
- 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 microsoft#844
@sergio-sisternes-epam sergio-sisternes-epam added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 25, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Cursor MCP configuration generation so that apm writes a .cursor/mcp.json that matches Cursor's expected schema (instead of inheriting Copilot CLI formatting).

Changes:

  • Add Cursor-specific _format_server_config() that emits Cursor-compatible type values and omits Copilot-only fields.
  • Add unit tests covering Cursor MCP config formatting for stdio, http remotes, and package-based servers.
  • Harden load_policy() to avoid crashing when Path.is_file() raises on non-path inputs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
src/apm_cli/adapters/client/cursor.py Overrides server-config formatting to match Cursor MCP JSON expectations.
tests/unit/test_cursor_mcp.py Adds coverage to ensure Cursor config output uses correct type and excludes Copilot-only fields.
src/apm_cli/policy/parser.py Wraps Path.is_file() to tolerate OS-level path errors when source is not a real file path.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +156 to +169
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.
"""
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New/modified method is missing type hints for parameters and return value, which makes the adapter contract harder to follow and conflicts with the repo guideline requiring type hints. Please add appropriate annotations (e.g., dict[str, Any] / Optional[dict[str, str]] / dict[str, Any]).

Copilot generated this review using guidance from repository custom instructions.
Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +185 to +203
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
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote (http) server formatting dropped the Copilot behavior of resolving header placeholders via _resolve_env_variable and adding GitHub auth headers when applicable. This is a functional regression vs the previously-inherited Copilot formatter and can leave Cursor configs with unusable headers (e.g., values containing '' placeholders) or missing Authorization for GitHub-hosted remotes. Please resolve/normalize headers the same way as Copilot (including _warn_input_variables for ${input:...} in headers) while still omitting Cursor-incompatible fields (tools/id).

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/policy/parser.py Outdated
if path.is_file():
try:
is_file = path.is_file()
except OSError:
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_policy() now guards Path.is_file() with an OSError handler, but Path/is_file can also raise ValueError for invalid path strings (e.g., embedded NUL bytes). Elsewhere in the repo path operations commonly catch (OSError, ValueError). Consider catching ValueError here as well so YAML-string inputs can't crash with an unhandled exception.

Suggested change
except OSError:
except (OSError, ValueError):

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +26 to +29
- ``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.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring introduces a Unicode em dash character (�> ASCII). Repo policy requires source + CLI strings to be printable ASCII only; please replace the em dash with ASCII (e.g., "-" or "--").

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +152 to +154
# ------------------------------------------------------------------ #
# _format_server_config — MUST override; do NOT silently inherit Copilot
# ------------------------------------------------------------------ #
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment header uses a Unicode em dash character (�> ASCII). The repo's encoding rule requires source files to be printable ASCII only; please replace it with ASCII punctuation (e.g., "--").

Copilot generated this review using guidance from repository custom instructions.
@github-actions
Copy link
Copy Markdown

APM Expert Review Panel

Verdict: APPROVE with minor requests (non-blocking) — two real bugs fixed, tests shipped, external contributor. Changes are safe to merge with the callouts below addressed before or after merge at maintainer discretion.


[Python Architect] — Architecture / Code Structure

Fixes are structurally sound. _format_server_config is the right override point and the docstring/comment making it an explicit "MUST override" contract is a healthy addition.

Three non-blocking concerns worth tracking:

  1. Code duplication (~110 lines of near-copy from CopilotClientAdapter._format_server_config). The inheritance design is explicitly documented as "known fragility" — acceptable for now, but the eventual fix is a protected _build_stdio_config() / _build_remote_config() helper on the base class that both adapters call, keeping type selection and field filtering as the only subclass responsibility.

  2. _apm_tools_override silently dropped in the Cursor package path. The Copilot adapter applies tools_override at the end of the package path; Cursor doesn't. If this is intentional (Cursor has no tools filtering), a one-line comment in the code would prevent the next contributor from "fixing" it by re-adding it.

  3. OSError catch scope in parser.py is intentionally broad — it silently treats any OS-level path error as "this is a YAML string, not a file". On Windows, errno.EINVAL fires for the same reason as ENAMETOOLONG on macOS, so the broad catch may actually be correct cross-platform. Worth verifying (or adding a comment citing both errno values).


[CLI Logging Expert] — Output / Logging Paths

No logging regressions introduced. The pre-existing print() calls in configure_mcp_server are unchanged — not this PR's problem. ValueError messages are detailed and consistent with the Copilot adapter. No concerns.


[DevX UX Expert] — Command Surface / Error Ergonomics

High-impact UX fix. Cursor was silently rejecting every server APM configured because the type: "local" field is invalid in Cursor's schema and tools/id fields caused silent rejection. Users would see APM report success, then find nothing in Cursor — a very bad first-run experience.

This fix unblocks all Cursor users from a silent failure. No new UX regressions observed.

The policy parser fix (OSError guard) unblocks CI/automated use-cases where policy is passed as a long YAML string on macOS — equally important for programmatic users.


[Supply Chain Security Expert] — Headers, Auth, Token Scope

One functional gap worth a follow-up issue:

In the Copilot adapter's remote path, each registry-defined header value goes through _resolve_env_variable() before being written to config, so ${API_KEY} or ${input:TOKEN} placeholders are expanded. The Cursor adapter's remote path writes h["value"] directly:

# cursor.py (new code)
config["headers"] = {
    h["name"]: h["value"] for h in headers if "name" in h and "value" in h
}
# vs copilot.py:
resolved_value = self._resolve_env_variable(header_name, header_value, env_overrides)
config["headers"][header_name] = resolved_value

For MCP servers that require auth headers with env-var placeholders, Cursor users will get unexpanded ${VAR} strings in their config. No security risk, but a functional regression relative to Copilot behavior. Suggest opening a follow-up issue.

No new attack surfaces, no path traversal risk, no credential leakage.


[OSS Growth Hacker] — Adoption Impact

Cursor has significant market share among AI developers — this fix directly improves conversion for a large segment of APM's potential user base. Silent MCP rejection was the worst possible experience for a first-timer on Cursor.

Recommend calling this out explicitly in the [Unreleased] CHANGELOG section — it's buried otherwise.


[APM CEO] — Strategic Rationale / Final Verdict

Both fixes address real reported issues (#844, #848) with clear reproduction paths and test coverage. The contributor shipped tests for the new behavior which is exactly what we want from external contributors — accept the pattern encouragingly.

Missing CHANGELOG entries — required per our conventions. Please add before merge:

### Fixed
- Cursor MCP adapter now emits `type: "stdio"` / `type: "http"` instead of `type: "local"`, and omits Copilot-specific `tools` and `id` fields that caused Cursor to silently reject configured servers. Closes #844 (#869)
- `load_policy()` no longer raises `OSError [Errno 63] File name too long` on macOS when given a YAML string longer than PATH_MAX. Closes #848 (#869)

Action items (non-blocking — maintainer discretion):

  • Add CHANGELOG entries (required by convention)
  • Consider follow-up issue for header ${VAR} resolution gap in Cursor's remote path
  • Consider follow-up issue for _apm_tools_override documentation in Cursor adapter

Merge when CHANGELOG is added. Everything else can be tracked separately.

Generated by PR Review Panel for issue #869 · ● 1.1M ·

Copy link
Copy Markdown
Collaborator

@sergio-sisternes-epam sergio-sisternes-epam left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review -- PR #869 Fix/issue 844 cursor MCP JSON schema

Thanks for tackling #844 -- the diagnosis is correct, the type mapping is exactly what Cursor needs, and the tests cover the key happy paths. Good work identifying the root cause.

However, the implementation strategy (full method fork) introduces functional regressions that recreate the same class of silent-failure bug that #844 reported. Three independent reviewers (architecture, security, DX) converge on the same recommendation.


Blocking findings

1. super() + transform instead of ~100-line fork
The override duplicates the parent but silently drops: GitHub MCP auto-auth, header env-var resolution, _warn_input_variables, and _apm_tools_override. OpenCodeClientAdapter already proves the delegate-then-transform pattern works in this codebase (~8 lines vs ~100).

2. GitHub MCP server auth silently dropped (Security: HIGH)
The parent detects GitHub servers via _is_github_server() and injects a Bearer token. This override skips it. apm install github-mcp-server --client cursor will produce an unauthenticated config -- the server returns 401 and Cursor shows nothing useful. Same class of silent-failure as #844.

3. Header env-var resolution missing (Security: MEDIUM)
The parent resolves ${input:...} and env-var references in header values and warns about unresolvable ones. This override copies raw values as literal strings.

4. Docstring endorses copy-paste overrides
The new docstring says _format_server_config "must be explicitly overridden in each subclass". This codifies tech debt as policy. The correct lesson (proven by OpenCode) is to call super() and transform.


Non-blocking

  • Unrelated policy/parser.py change -- reasonable, but should be a separate PR. Also consider catching (OSError, ValueError).
  • Module docstring should note Cursor's schema differs from Copilot's.
  • Missing test for GitHub auth -- once the auth gap is fixed, add a test mocking GitHubTokenManager + _is_github_server.
  • Edge-case test gaps -- empty env, docker path, ValueError on missing packages, SSE/streamable-http transport normalisation.
  • Missing CHANGELOG entry under [Unreleased].
  • PR body is still template placeholder -- please fill in linking to #844.
  • Merge conflicts need resolving (rebase onto main).

Happy to re-review once updated. The core fix is right -- it just needs the cleaner implementation strategy to avoid trading one silent-failure bug for another.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +24 to +35
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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring codifies the wrong architectural lesson.

"This inheritance design is a known fragility. _format_server_config must be explicitly overridden in each subclass"

This endorses the copy-paste approach as policy. The correct lesson (proven by OpenCodeClientAdapter) is that subclasses should call super() and transform the result. Please update this to document the delegate-then-transform pattern instead.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +156 to +261
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace this ~100-line override with super() + transform (~8 lines).

All three reviewers (architecture, security, DX) converge on this. The override duplicates the parent but silently drops: GitHub MCP auto-auth, header env-var resolution, _warn_input_variables, and _apm_tools_override. OpenCodeClientAdapter already proves the delegate-then-transform pattern works in this codebase:

def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None):
    config = super()._format_server_config(server_info, env_overrides, runtime_vars)
    config.pop("tools", None)
    config.pop("id", None)
    if config.get("type") == "local":
        config["type"] = "stdio"
    return config

This fixes every functional regression flagged below in one go and automatically inherits future parent improvements.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +185 to +203
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: GitHub MCP server authentication silently dropped.

The parent (copilot.py L198-210) detects GitHub servers via _is_github_server() and injects Authorization: Bearer <token> using GitHubTokenManager. This override skips that entirely.

User impact: apm install github-mcp-server --client cursor produces a config with no auth header. The server returns 401, Cursor shows nothing useful. This is the same class of silent-failure bug that #844 reports -- just on a different code path.

This is a fail-open pattern that violates APM's fail-closed security posture. Inherited for free with super() + transform.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +195 to +202
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header env-var references written as literal strings.

The parent resolves ${input:...} and env-var references via _resolve_env_variable() (L222) and warns about unresolvable ones via _warn_input_variables() (L227). This override copies raw values, so headers containing placeholders like ${input:API_KEY} are written literally to .cursor/mcp.json.

Also inherited for free with super() + transform.

Comment thread src/apm_cli/adapters/client/cursor.py Outdated
Comment on lines +19 to +35
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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the class docstring should note that the config schema differs from Copilot's.

After this PR, a developer reading the source will see the module-level docstring (L1-17) still implies the config schema is identical to Copilot's. Worth a one-line clarification that Cursor uses stdio/http instead of local and omits tools/id.

Comment thread src/apm_cli/policy/parser.py Outdated
Comment on lines +242 to +245

if path.is_file():
try:
is_file = path.is_file()
except OSError:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope: this change is unrelated to issue #844.

The OSError guard is reasonable defensive coding (and security-acceptable per review). However, mixing it into a Cursor MCP fix makes the PR harder to review and cherry-pick. Please split into its own PR or at minimum its own commit with a clear message.

Also consider catching (OSError, ValueError) -- ValueError is raised for paths containing NUL bytes.

Comment on lines +155 to +175
@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"])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test: GitHub MCP server auth headers.

This test verifies type: "http" and absence of tools/id but doesn't assert that a GitHub MCP server gets an Authorization: Bearer ... header. Once the auth gap is fixed (ideally via super() + transform), add a test that mocks GitHubTokenManager.get_token_for_purpose and _is_github_server and asserts the header is present.

Comment on lines +177 to +198
@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"])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: a few more edge-case tests would strengthen coverage.

Consider adding:

  • Empty env: {} to verify it's omitted from output
  • Docker package path (has its own branch in the implementation)
  • Negative test where packages is empty and no remotes exist (should raise ValueError)
  • sse or streamable-http transport type to verify the normalisation to "http"

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 microsoft#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 microsoft#844
Fixes microsoft#848
@chaobo8484
Copy link
Copy Markdown
Contributor Author

@sergio-sisternes-epam I have made adjustments based on your and Copilot's suggestions. Wish you a wonderful day!

@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 28, 2026
@github-actions
Copy link
Copy Markdown

CLI Logging / Output UX Review — PR #869

Summary

The _format_server_config refactoring itself (super()+transform) is clean — no logging concerns there. But the configure_mcp_server override carries forward pre-existing raw print() calls that violate APM's structured output conventions. Since this PR overrides configure_mcp_server for Cursor, it's the right time to fix these in the Cursor adapter rather than let them calcify further.

Verdict: Request Changes — one blocking issue (em-dash in inherited _warn_input_variables), several non-blocking issues that should be addressed since this PR already touches the method.


FINDINGS

F1. Raw print() — systemic adapter-layer debt (non-blocking for this PR, but fixable here)

All six client adapters use raw print() instead of CommandLogger / _rich_* helpers. This is a codebase-wide debt — not introduced by this PR — but cursor.py now has its own copy of the pattern and can lead the migration.

File Raw print() count
copilot.py 5
cursor.py 4
codex.py 7
opencode.py 4
vscode.py 6
base.py 1

The VSCode adapter is the only one that partially supports a logger parameter (configure_mcp_server(..., logger=None)), showing the intended migration direction. Cursor should follow that lead.

F2. _warn_input_variables emits an em-dash (\u2014) — ASCII violation 🔴

# base.py:123
f"'{server_name}' will not be resolved \u2014 "

This is (U+2014 EM DASH). On Windows cp1252 terminals and some CI log viewers this renders as â€" or ?. The entire purpose of STATUS_SYMBOLS being ASCII-safe is to prevent exactly this. When _format_server_configsuper()_warn_input_variables fires during Cursor config, this broken character hits the user's terminal.

This is the only blocking issue. It's in base.py, not cursor.py, but Cursor inherits and actively triggers this code path.

F3. Silent return on missing .cursor/ directory — acceptable, but verbose-worthy

# cursor.py:116-117
if not cursor_dir.exists():
    return True  # nothing to do, not an error
```

Returning `True` silently is correct for opt-in adapters (OpenCode does the same). But in `--verbose` mode, an agent or power user troubleshooting "why didn't Cursor get configured?" has zero signal. A verbose-only message would help:

```
[i] Cursor -- skipping (.cursor/ directory not found)

F4. Bare except Exception swallows stack traces

# cursor.py:148-149
except Exception as e:
    print(f"Error configuring MCP server: {e}")
    return False

In non-verbose mode, hiding the traceback is fine. But in --verbose mode, the traceback is the single most useful debugging artifact. Every other part of APM's logging design follows progressive disclosure — default gives outcome, verbose gives diagnostics. This except block gives the same minimal output regardless of mode.

F5. Error messages lack STATUS_SYMBOLS prefix

Compare cursor.py output with what CommandLogger.error() would produce:

Current (cursor.py) Expected (APM convention)
Error: server_url cannot be empty [x] Error: server_url cannot be empty
Error: MCP server 'X' not found in registry [x] MCP server 'X' not found in registry
Successfully configured MCP server 'X' for Cursor [*] Configured MCP server 'X' for Cursor
Error configuring MCP server: ... [x] Error configuring MCP server: ...

Also note: "Successfully configured" is wordier than APM's usual outcome-first style. Should be "Configured" — the [*] symbol already conveys success.

F6. No color differentiation

Raw print() outputs everything in the terminal's default color. APM's traffic-light convention:

  • Red (_rich_error) = errors the user must act on
  • Green (_rich_success) = success confirmation
  • Yellow (_rich_warning) = should-know warnings
  • Dim (verbose_detail) = debug-level detail

Currently, all four cursor.py messages — errors, success, and any inherited warnings — render in the same undifferentiated color. This makes scanning output significantly harder, especially when installing across multiple clients simultaneously.


VIOLATIONS (specific lines)

# File:Line Code Violation
V1 base.py:123 \u2014 (em-dash) ASCII compliance — will corrupt on Windows cp1252 terminals
V2 cursor.py:111 print("Error: server_url cannot be empty") Raw print, no [x] symbol, no red color
V3 cursor.py:127 print(f"Error: MCP server '{server_url}' not found...") Raw print, no [x] symbol, no red color
V4 cursor.py:143-145 print(f"Successfully configured...") Raw print, no [*] symbol, no green color, verbose wording
V5 cursor.py:149 print(f"Error configuring MCP server: {e}") Raw print, no [x] symbol, no traceback in verbose mode
V6 cursor.py:116-117 Silent return True No verbose-mode signal for skipped adapter

REQUIRED CHANGES

Blocking (must fix before merge):

  1. Fix the em-dash in base.py:123 — replace \u2014 with -- (ASCII double-dash). This affects all adapters inheriting _warn_input_variables, and Cursor actively triggers it via the super() delegation pattern this PR introduces.

    # base.py:121-125 — before
    print(
        f"[!]  Warning: ${{input:{var_id}}} in server "
        f"'{server_name}' will not be resolved \u2014 "
        f"{runtime_label} does not support input variable prompts"
    )
    
    # after
    print(
        f"[!]  Warning: ${{input:{var_id}}} in server "
        f"'{server_name}' will not be resolved -- "
        f"{runtime_label} does not support input variable prompts"
    )

Recommended (should fix in this PR since configure_mcp_server is already being modified):

  1. Migrate cursor.py's four print() calls to _rich_* helpers. This is a 4-line change that doesn't require plumbing a logger parameter — just import and call directly:

    from ...utils.console import _rich_error, _rich_success, _rich_info
    
    # Line 111
    _rich_error("server_url cannot be empty", symbol="error")
    
    # Line 127
    _rich_error(f"MCP server '{server_url}' not found in registry", symbol="error")
    
    # Line 143-145
    _rich_success(f"Configured MCP server '{config_key}' for Cursor", symbol="success")
    
    # Line 149
    _rich_error(f"Error configuring MCP server: {e}", symbol="error")
  2. Add a verbose-mode message for the silent .cursor/ skip (cursor.py:116-117). Not blocking, but completes the diagnostic picture for --verbose users.

Future (track separately):

  1. Migrate all adapters to accept an optional logger: CommandLogger parameter (following VSCode's existing pattern). This is a cross-cutting refactor and shouldn't block this PR.

VERDICT: Request Changes

  • Blocking: V1 (em-dash \u2014 in base.py) — ASCII compliance violation that actively fires through the code path this PR introduces
  • Strongly recommended: V2–V5 — since configure_mcp_server is being overridden in this PR, migrating its 4 print() calls to _rich_* helpers is minimal effort and prevents the anti-pattern from calcifying
  • Nice to have: V6 — verbose-mode diagnostic for skipped adapter

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

@github-actions
Copy link
Copy Markdown

🔒 Supply Chain Security Review — PR #869

FINDINGS

🔴 F1 — CRITICAL: Bearer token written to repo-local .cursor/mcp.json

This PR introduces a credential leakage regression.

The old Cursor _format_server_config handled remotes independently and never injected a GitHub Bearer token — it only passed through raw registry-supplied headers (raw h["value"], not resolved tokens).

The new code delegates to super()._format_server_config(), which calls _is_github_server() and injects a resolved plaintext PAT into the config dict:

# copilot.py:207-212 — this now runs for Cursor too
_tm = GitHubTokenManager()
github_token = _tm.get_token_for_purpose('copilot') or os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
if github_token:
    config["headers"] = {
        "Authorization": f"Bearer {github_token}"  # ← actual PAT value
    }

The Cursor adapter then writes this dict verbatim to .cursor/mcp.json — a repo-local file:

# cursor.py:51
cursor_dir = Path(os.getcwd()) / ".cursor"

Attack path: User runs apm install → token is written to .cursor/mcp.json → user commits .cursor/ (which is NOT in .gitignore) → PAT is pushed to a shared repo / GitHub.

Violated guarantee from enterprise/security.md L255:

Never stored in files. Tokens are read from the environment at runtime. They are never written to apm.yml, apm.lock.yaml, or any generated file.

For comparison, the Copilot adapter writes to ~/.copilot/mcp-config.json (user-home, not repo-local) — which is lower risk (analogous to ~/.npmrc). The Cursor path is fundamentally different.

The new test test_github_remote_server_gets_auth_header explicitly asserts this behavior, treating it as intended. From a security perspective, this test codifies a credential leak.


🟡 F2 — MEDIUM: Resolved env-var secrets also written to repo-local disk

The parent's _resolve_env_variable() (copilot.py:397) resolves <ENV_VAR> patterns to their actual values. For headers like X-API-Key: <MY_SECRET_KEY>, the resolved plaintext value ends up in the config dict and is written to .cursor/mcp.json.

The old Cursor code only passed through raw unresolved header values from the registry (h["value"]), which might still contain the <VAR> placeholder rather than the resolved secret.


🟢 F3 — LOW: _warn_input_variables label says "Copilot CLI" instead of "Cursor"

The parent calls _warn_input_variables(..., "Copilot CLI"). The old Cursor code called it with "Cursor". After the refactor, Cursor users will see warnings referencing "Copilot CLI", which is confusing but not a security issue.


✅ F4 — INFO: uv.lock change is clean

Only change: apm-cli self-version 0.9.1 → 0.9.2 (source: editable = "."). No third-party dependency additions, removals, or version changes. No supply chain risk.


✅ F5 — INFO: policy/parser.py ValueError catch is safe

Path.is_file() raises ValueError on Windows for paths with embedded null bytes (and on some Python versions for paths exceeding PATH_MAX). The fix catches it and defaults to is_file = False, treating the input as a YAML string. This is fail-closed — it refuses to interpret the string as a file path, not the reverse. Tests confirm the behavior. No security concern.


✅ F6 — INFO: Token scope (get_token_for_purpose('copilot'))

The copilot chain (GITHUB_COPILOT_PAT → GITHUB_TOKEN → GITHUB_APM_PAT) is a pre-existing design shared with the Copilot adapter. Whether GITHUB_APM_PAT (a fine-grained PAT scoped for module access) is appropriate for github-mcp-server is a broader architectural question — not introduced by this PR. Deferring to Auth expert for that assessment.


REQUIRED CHANGES

# Severity Requirement
R1 🔴 BLOCKER Do not write resolved Bearer tokens to .cursor/mcp.json. The Cursor adapter must strip or redact Authorization headers from the config dict before it reaches update_config()json.dump(). Options: (a) post-transform, replace the Authorization value with a ${env:GITHUB_TOKEN} placeholder that Cursor resolves at runtime, or (b) pop the Authorization header entirely and document that users must configure auth separately, or (c) use Cursor's native env-var-in-header support if available.
R2 🟡 SHOULD Audit all resolved header values for secrets before disk write. Any header whose value came from _resolve_env_variable() should NOT be written as plaintext to a repo-local file. Consider writing placeholders or using Cursor's env block with variable references.
R3 🟢 NICE-TO-HAVE Override _warn_input_variables calls to use "Cursor" as the runtime_label, or have the parent accept it as a class attribute.
R4 🟢 NICE-TO-HAVE Consider adding .cursor/mcp.json to the project's .gitignore template as a defense-in-depth measure.

VERDICT

🚫 Request Changes (for supply-chain security area)

R1 is a blocking credential leakage regression. The old Cursor code deliberately avoided injecting tokens; the super() delegation undoes that safety property. The refactor to delegate-then-transform is architecturally sound, but the transform step must also sanitize credentials before they reach the repo-local disk path. Until R1 is addressed, this PR should not merge.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

@github-actions
Copy link
Copy Markdown

🏗️ Python Architecture Review — PR #869

Reviewer: Python Architect
Scope: CursorClientAdapter._format_server_config refactor to delegate-then-transform pattern


FINDINGS

F1 ✅ Delegate-then-transform pattern is architecturally sound

The refactor from ~100 LOC copy-paste fork to super()._format_server_config() + 15 LOC transform is exactly the right call. MRO confirmed:

CursorClientAdapter → CopilotClientAdapter → MCPClientAdapter → ABC → object

super()._format_server_config(...) resolves to CopilotClientAdapter._format_server_config — no diamond, no ambiguity. This is single-inheritance delegation, the simplest and most predictable MRO pattern.

Key win: Cursor now automatically inherits all parent features — GitHub auth, header env-var resolution, _warn_input_variables, _apm_tools_override, package-type routing — instead of silently dropping them.

F2 ⚠️ The elif config.get("type") == "http" branch is dead code

Traced through every return path in CopilotClientAdapter._format_server_config:

Parent code path Returns type = Cursor elif hit?
_raw_stdio branch (L174-184) "local" No — hits the if "local" branch
remotes branch (L188-236) "http" Yes — enters elif
packages branch (L248-318) "local" No — hits the if "local" branch

When the elif fires (parent returned "http" for remotes), the inner body does:

transport_type = server_info.get("remotes", [{}])[0].get("transport_type", "")
if transport_type in ("sse", "streamable-http"):
    config["type"] = "http"  # ← already "http", this is a no-op

The parent already emits "http" unconditionally for all remote types (L193: "type": "http"). The elif branch cannot change anything — it's vestigial from the old copy-paste implementation where Cursor had its own remote-handling logic with transport-type switching. There is no else clause that would alter the type, so "http" stays "http" regardless.

Impact: Not a bug (it's inert), but it's misleading. A reader will assume there's a reason for the transport_type check, and future maintainers may try to "fix" it.

F3 ✅ Signature compatibility is fine

The override adds Optional type hints that the parent lacks:

# Parent
def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None):

# Override
def _format_server_config(self, server_info: dict, env_overrides: Optional[dict] = None, runtime_vars: Optional[dict] = None) -> dict:

Python doesn't enforce signature covariance at runtime. The defaults match. The added hints are strictly additive — they improve tooling (mypy, IDE autocomplete) without breaking LSP. Good.

F4 i️ Pattern differs from OpenCode, but appropriately so

OpenCodeClientAdapter does not override _format_server_config. Instead, it delegates at the configure_mcp_server level and uses a static _to_opencode_format() to transform the Copilot-format dict into OpenCode schema (different key names: environment vs env, command array vs split).

Cursor's transformation is simpler (type rename + field removal), so overriding _format_server_config directly is the cleaner choice — it keeps the transform co-located with the formatting concern. The two adapters make different design tradeoffs appropriate to their schema distance from Copilot. No consistency issue here.

F5 ⚠️ Hardcoded "Copilot CLI" in _warn_input_variables calls

When Cursor's super()._format_server_config() calls _warn_input_variables(...), the parent hardcodes "Copilot CLI" as the runtime_label (copilot.py L179, L229):

self._warn_input_variables(raw["env"], server_info.get("name", ""), "Copilot CLI")

This means a Cursor user will see: "Warning: ${input:...} will not be resolved — Copilot CLI does not support input variable prompts" — referring to the wrong runtime. This is a pre-existing issue (not introduced by this PR) but is now more visible because the delegate pattern actually exercises this code path for Cursor.

F6 ✅ Test coverage is good, with one gap

The test file covers:

  • type: "local""stdio" (stdio, packages)
  • type: "http" preserved for remotes
  • tools and id stripped
  • ✅ GitHub auth headers flow through
  • _warn_input_variables exercised
  • ✅ Header env-var resolution
  • ⚠️ Missing: No test for _apm_tools_override being stripped. When server_info includes "_apm_tools_override": ["tool1"], the parent sets config["tools"] = ["tool1"], and Cursor should strip it. This path works (the pop("tools", None) is unconditional), but it's not explicitly tested.

REQUIRED CHANGES

R1. Remove the dead elif branch (F2)

The entire elif block on lines 172-177 is unreachable dead code that cannot change any output. It should be removed to prevent maintenance confusion:

def _format_server_config(self, server_info: dict, ...) -> dict:
    config = super()._format_server_config(server_info, env_overrides, runtime_vars)

    # Cursor uses "stdio" where Copilot uses "local"
    if config.get("type") == "local":
        config["type"] = "stdio"
    # Parent already emits "http" for all remote types — no transform needed.

    # Remove Copilot-only fields
    config.pop("tools", None)
    config.pop("id", None)

    return config

This is a required change because dead code with a plausible-looking comment is an active maintainability hazard — someone will try to extend that branch thinking it does something.


OPTIONAL IMPROVEMENTS

O1. Add a test for _apm_tools_override stripping (F6)

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)

O2. Track the "Copilot CLI" label issue (F5)

Not a blocker for this PR, but the hardcoded "Copilot CLI" in parent's _warn_input_variables calls will show the wrong runtime label when invoked via Cursor or OpenCode. A follow-up could add a runtime_label class attribute to MCPClientAdapter so each adapter reports its own name. File an issue if not already tracked.

O3. Docstring: remove reference to SSE normalization

The class docstring (cursor.py L34) mentions sse/streamable-http transports normalized to "http" — if the elif is removed per R1, this line should be updated to simply note that the parent handles all remote types as "http".


VERDICT: Request Changes (one required change)

The architectural pattern is excellent — this is exactly how child adapters should relate to CopilotClientAdapter. The refactor eliminates ~80 LOC of duplicated logic and restores silently-dropped features. The single required change (R1: remove dead elif branch) is surgical and risk-free. Once that's addressed, this is a clean approve from the architecture perspective.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

@github-actions
Copy link
Copy Markdown

CEO / Strategic Owner — Final Verdict on PR #869

Verdict: Request Changes — 1 security blocker + 3 required fixes, then this merges.


To the contributor

@10-8byte — thank you for this PR. The super()+transform pattern is a genuinely excellent refactor: you eliminated ~100 lines of copy-paste, made Cursor inherit every future improvement to the parent, and wrote thorough tests. That's exactly the kind of structural contribution that makes a project healthier. I want to merge this; the four items below are what stand between this PR and main.


Finding Classification

# Finding Classification Owner
F1 Bearer PAT written to repo-local .cursor/mcp.json 🔴 BLOCKER Author
F2 Dead elif config.get("type") == "http" branch (lines 172-177) 🟡 Required Author
F3 _warn_input_variables says "Copilot CLI" for Cursor users 🟡 Required Author
F4 CHANGELOG says "restores" GitHub auto-auth — it's net-new for Cursor 🟡 Required Author
F5 \u2014 em-dash in base.py:123 garbled on Windows cp1252 ⚪ Pre-existing / out of scope Follow-up issue
F6 print()_rich_error()/_rich_success() migration ⚪ Nice-to-have Follow-up OK
F7 Silent no-op when .cursor/ missing (no verbose breadcrumb) ⚪ Nice-to-have Follow-up OK
F8 Bare except Exception (no --verbose differentiation) ⚪ Nice-to-have Follow-up OK
F9 No test for _apm_tools_override being stripped ⚪ Nice-to-have Follow-up OK

F1 — Token Leakage (Blocker): Detailed Rationale

This is the one finding I cannot defer. Here is the evidence chain:

  1. Old cursor code (cursor.py before this PR, lines 185-205) handled remotes by copying registry headers verbatim. It never called GitHubTokenManager and never injected a Bearer token.

  2. New cursor code calls super()._format_server_config(), which at copilot.py:204-212 detects GitHub servers, resolves a PAT via GitHubTokenManager, and injects Authorization: Bearer <actual_token> into the config dict.

  3. The config dict is then written by update_config()json.dump() to .cursor/mcp.json — a repo-local file, inside the git working tree. Compare: Copilot writes to ~/.copilot/mcp-config.json (user home directory, outside any repo).

  4. There is no .gitignore entry for .cursor/mcp.json. A single git add . or git add -A will stage the resolved PAT for commit.

  5. This PR's own test proves ittest_github_remote_server_gets_auth_header (line 202-225 of test_cursor_mcp.py) asserts that "Bearer ghp_test_token_12345" is written to the .cursor/mcp.json file on disk.

  6. The CHANGELOG says this "restores" GitHub MCP auto-auth for Cursor, but the old Cursor code never had it. This is net-new behavior with a different threat model than the Copilot adapter.

The fix is small. In _format_server_config's transform step (after super() returns), strip or neutralize the resolved Authorization header before it reaches disk. Simplest approach:

# After config = super()._format_server_config(...)
# Strip resolved bearer tokens — Cursor is repo-local, tokens must not hit disk
headers = config.get("headers", {})
if headers.get("Authorization", "").startswith("Bearer "):
    del headers["Authorization"]
    if not headers:
        config.pop("headers", None)

A follow-up issue can implement proper Cursor-compatible auth (env-var references like ${GITHUB_TOKEN} rather than resolved values). The important thing is that this PR does not ship a code path that writes PATs to the repo tree.

F2 — Dead elif Branch (Required)

All four reviewers flagged this. The parent at copilot.py:194 always sets type: "http" for remote servers unconditionally. The elif config.get("type") == "http" branch (cursor.py lines 172-177) re-assigns "http""http" — it's a no-op. Remove it.

F3 — "Copilot CLI" Label Regression (Required)

The old cursor code explicitly passed "Cursor" as the runtime_label to _warn_input_variables. The new super() delegation inherits the hardcoded "Copilot CLI" from copilot.py:179 and copilot.py:229. Cursor users now see:

Warning: ${input:api-token} in server 'X' will not be resolved — Copilot CLI does not support input variable prompts

This is a regression from this PR. Fix options (in order of preference):

  • Add a class attribute runtime_label = "Cursor" and have the parent read it (cleanest, but touches parent)
  • Override _warn_input_variables in CursorClientAdapter to pass the correct label
  • Post-process warnings (least clean)

F4 — CHANGELOG Wording (Required)

The CHANGELOG entry says "This restores GitHub MCP auto-auth" — but the old Cursor adapter never had GitHub auto-auth. If F1 is fixed by stripping the token, then the CHANGELOG should not claim auto-auth at all. If a follow-up implements env-var-based auth, that's where the CHANGELOG entry belongs.


Items Explicitly Out of Scope

  • Em-dash in base.py:123: Pre-existing, pre-dates this PR. The \u2014 Python escape renders as U+2014 at runtime. Will file a separate issue.
  • print() → rich helpers migration: The old cursor.py also used raw print(). Not a regression. Good cleanup for a follow-up.
  • --verbose mode improvements: Desirable but not blocking this bug fix.

Summary for the author

Action Effort
Strip resolved Bearer token from config before disk write ~5 lines
Remove dead elif branch ~5 lines removed
Fix runtime label to say "Cursor" not "Copilot CLI" ~3-10 lines
Update CHANGELOG to remove "restores auto-auth" claim ~1 line

Total: roughly 15 lines changed. The architectural pattern stays exactly as-is — these are surgical fixes on top of your excellent refactor. Once addressed, I'm happy to approve.

Thank you for your patience and for the high quality of this contribution. 🙏

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

@github-actions
Copy link
Copy Markdown

APM Review Panel — PR #869

fix(cursor): Cursor MCP adapter type field fix + delegate-then-transform refactor
Author: @10^8byte  |  Fixes: #844, #848  |  +184 / -124 across 6 files


What this PR actually changes

The large diff is misleading — the branch has three upstream merge commits folded in. The real changes are six files:

File Change
src/apm_cli/adapters/client/cursor.py Core fix: delegate-then-transform pattern
src/apm_cli/policy/parser.py Minor: catch ValueError alongside OSError
tests/unit/test_cursor_mcp.py +7 new tests
tests/unit/policy/test_parser.py +3 new tests for parser edge cases
CHANGELOG.md Entries for both fixes
uv.lock Routine bump

The PR has two commits that tell the full story. The first commit (d642f8e) was an initial fix that unfortunately copy-pasted ~106 lines from the Copilot adapter and silently dropped GitHub auth, header env-var resolution, _warn_input_variables, and _apm_tools_override. The second commit (1fe66ab) correctly identifies and fixes that regression by switching to a super() delegate-then-transform pattern (~20 lines). Reviewing the HEAD state.


[Python Architect]

Verdict: APPROVE — with one optional simplification

The delegate-then-transform pattern in the final state is correct. Call super()._format_server_config() to get the fully-resolved Copilot config (GitHub Bearer injection, header env-var resolution, _warn_input_variables, _apm_tools_override), then translate the incompatible fields for Cursor's schema. This is the right layering.

Finding: Dead code in the SSE normalization block (low severity, non-blocking)

# cursor.py lines ~172-177
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"   # <-- no-op

CopilotClientAdapter._format_server_config always emits "type": "http" for all remote endpoints regardless of the original transport_type (SSE, streamable-http, etc.) — the parent never exposes the raw transport string in the returned dict. So this elif branch is only entered when the parent already returned "http", and the inner assignment is always "http" = "http". The normalization the comment describes is already handled upstream.

The tests (test_sse_transport_normalized_to_http, test_streamable_http_transport_normalized_to_http) pass for the right reason (parent normalises), not because of these lines. Harmless, but consider simplifying:

# After: all that's needed
if config.get("type") == "local":
    config["type"] = "stdio"
config.pop("tools", None)
config.pop("id", None)

Finding: _apm_tools_override semantics for Cursor (informational)

The commit message says "Restore _apm_tools_override" — what actually happens is the parent executes the override code path (setting config["tools"]), and then the cursor adapter immediately strips it with config.pop("tools", None). This is correct (Cursor's MCP schema has no tools field), but "restore" is a slightly misleading verb. Worth a one-line docstring note: _apm_tools_override is intentionally a no-op for Cursor targets since the schema does not support tool filtering. No code change needed, just documentation clarity.

Observation on OpenCodeClientAdapter: it does not override _format_server_config and inherits the Copilot version directly. If OpenCode also diverges from Copilot's schema, it may have the same class of silent-failure bug. Out of scope for this PR, but worth tracking.


[Supply Chain Security + Auth Expert]

Verdict: APPROVE — no regressions, GitHub auth correctly restored

The GitHub Bearer token injection path is correctly restored. The parent's remote-handling code calls GitHubTokenManager().get_token_for_purpose('copilot') and _is_github_server(), then injects Authorization: Bearer <token> into the headers dict. Because the cursor adapter now calls super(), this full authentication chain runs before the transform step strips only tools and id — the headers dict (including any injected Bearer token) passes through untouched.

test_github_remote_server_gets_auth_header correctly verifies this:

self.assertEqual(server_cfg["headers"]["Authorization"], "Bearer ghp_test_token_12345")
self.assertNotIn("tools", server_cfg)
self.assertNotIn("id", server_cfg)

parser.py fix is correct. On Windows, Path.is_file() raises ValueError when the path string contains embedded NUL bytes (\x00). The existing except OSError guard did not catch this, causing load_policy() to crash on malformed path-like strings. Catching (OSError, ValueError) is the right fix; both are purely defensive guards around an optional file-existence check.

No new credential exposure. The _resolve_env_variable and _resolve_environment_variables paths run inside the parent, so any prompt-suppression logic (skip_prompting) and secret masking is inherited correctly.


[CLI Logging / UX Expert]

Verdict: APPROVE

Existing print() statements in configure_mcp_server are consistent with the rest of the Copilot adapter and are not changed. No new logging anti-patterns. Docstrings on the revised _format_server_config are accurate and describe the delegate-then-transform contract clearly. The non-ASCII em dashes (-- fix) in comments/docstrings is a welcome hygiene cleanup per the project's cross-platform encoding rule.


[DevX / UX Expert]

Verdict: APPROVE — real user-facing fix

Before this PR, installing any MCP server for Cursor via apm install would produce a broken .cursor/mcp.json:

  • type: "local" — Cursor silently rejects this; server never loads.
  • type: "http" remotes with no Authorization header — GitHub MCP auth silently fails.
  • tools: ["*"] and id: "..." fields — Cursor's MCP loader rejects configs with unknown keys.

All three are fixed at HEAD. The behavior is now correct for all server types: stdio packages, raw stdio, and HTTP remotes. The seven new tests cover the critical paths including GitHub Bearer auth injection and SSE/streamable-http normalization. Good test coverage for a bug fix.


[OSS Growth / CEO] — Arbitration

Verdict: APPROVE and MERGE

This is a quality external contribution. The two-commit story is exemplary: the author identified their own regression in the initial fix and proactively refactored to a better pattern. The final state is correct, well-tested, and meaningfully smaller than what it replaced.

The two findings from the Architect (SSE dead code, _apm_tools_override doc) are both non-blocking. The dead code does not affect correctness and the tests pass for the right reasons. Optional cleanup, not a merge blocker.

The OpenCodeClientAdapter observation is worth a separate tracking issue to audit other adapters for the same class of inheritance-without-override fragility.

Ship it.


Summary

Dimension Signal
Correctness Correct. type: stdio/http, tools/id stripped, GitHub auth restored.
Architecture super() delegate-then-transform is the right pattern. SSE dead code is minor.
Security No regressions. GitHub auth chain intact. parser.py crash fix correct.
Tests 10 new tests (7 cursor, 3 parser). Coverage is adequate for the changes.
Changelog Updated. Both fixes documented under [Unreleased].
Verdict APPROVE

Optional (non-blocking): simplify the SSE elif block (lines 172-177) and add a docstring note that _apm_tools_override is intentionally stripped for Cursor targets.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

@github-actions
Copy link
Copy Markdown

APM Expert Review Panel — PR #869

PR: fix(cursor): refactor _format_server_config to super()+transform pattern
Author: @10^8byte | Fixes: #844, #848


VERDICT: REQUEST CHANGES (4 required items, then merge)

The architectural refactor is exactly right and appreciated. The delegate-then-transform pattern is clean, the super() call is correct, and the core bug fix (#844) is directionally sound. Three surgical changes are needed before merge.


Findings by Area

Python Architect

Finding Class
elif config.get("type") == "http" branch (lines 172-177) is provably dead code. Parent already emits "http" unconditionally for all remote endpoints. The inner if transport_type in ("sse", "streamable-http"): config["type"] = "http" sets "http" to "http". 5-line removal. REQUIRED
MRO verified clean. Optional type hints are additive. Pattern is architecturally sound and consistent with OpenCodeClientAdapter. APPROVED
No test for _apm_tools_override stripping after Cursor transform NICE_TO_HAVE

Supply Chain Security

Finding Class
REGRESSION: super() delegation now inherits PAT injection from copilot.py:204-212. Cursor writes Authorization: Bearer <actual_PAT> into <repo>/.cursor/mcp.json -- inside the git tree. One git add . from public credential exposure. The old Cursor code never did this. The PR's own test_github_remote_server_gets_auth_header asserts the token lands on disk. BLOCK
Resolved ${env:VAR} secrets also flow to disk via the same path REQUEST_CHANGES
uv.lock bump (0.9.1→0.9.2), no new deps CLEAN
parser.py ValueError broadening is defensive/fail-closed CLEAN

DevX / UX

Finding Class
REGRESSION: Old code passed "Cursor" label to _warn_input_variables. super() delegation lost this -- users now see "Copilot CLI does not support input variable prompts" in a Cursor context. Wrong tool named. REQUIRED
Silent return True when .cursor/ missing -- user thinks install succeeded, MCP not configured, no breadcrumb REQUEST_CHANGES
Dead SSE elif branch misleads future maintainers NICE_TO_HAVE
CHANGELOG claims "restores GitHub auto-auth for Cursor" -- actually net-new, and now being stripped per security ruling REQUIRED (fix wording)

CLI Logging

Finding Class
4 raw print() calls in cursor.py should use _rich_error()/_rich_success() NICE_TO_HAVE (pre-existing)
Em-dash \u2014 in inherited _warn_input_variables in base class may crash Windows cp1252 terminals -- pre-existing, not this PR's bug PRE_EXISTING

CEO Arbitration

Token leakage vs DevX: Security wins. The old code never injected PATs into .cursor/mcp.json. Strip Authorization in the transform step (same place as tools/id).

Pre-existing parent-class issues (em-dash, print()): Not this PR's scope. Requiring an external contributor to fix parent-class bugs they didn't introduce is scope creep. Follow-up issues.

_warn_input_variables label regression: This PR's job -- the old code explicitly passed "Cursor". The loss is a direct regression introduced by the delegation.

CHANGELOG "restores" wording: Since the token injection is being stripped (security fix), remove the "restores GitHub MCP auto-auth" claim. Net behavior: type normalization + label fix.


Required Action Items Before Merge

1. [Security -- Blocker] Strip Authorization/Bearer headers in the _format_server_config transform, the same way tools and id are stripped. GitHub token must not be written to repo-local .cursor/mcp.json. Update test_github_remote_server_gets_auth_header to assert "Authorization" is absent from Cursor config.

# In _format_server_config, after config.pop("id", None):
if "headers" in config and "Authorization" in config.get("headers", {}):
    del config["headers"]["Authorization"]
if not config.get("headers"):
    config.pop("headers", None)

2. [Architecture -- Required] Remove the dead elif config.get("type") == "http" branch (lines 172-177 in the current file). The parent emits "http" unconditionally for remotes regardless of transport_type.

3. [UX -- Required] Fix the _warn_input_variables label regression. Cleanest approach: add a _runtime_label: str = "Copilot CLI" class attribute on CopilotClientAdapter, override to _runtime_label = "Cursor" in CursorClientAdapter, and thread self._runtime_label through _warn_input_variables calls. Add a test asserting the warning contains "Cursor", not "Copilot CLI".

4. [CHANGELOG -- Required] Remove the "restores GitHub MCP auto-auth" claim (the feature is being stripped per item 1). Accurately describe: type normalization ("local" -> "stdio"), tools/id stripping, _warn_input_variables label fix.


Optional Follow-ups (Post-Merge Issues)

  • Add informational output when .cursor/ is missing so users know config was skipped.
  • Migrate cursor.py's 4 print() calls to _rich_error()/_rich_success().
  • Add test for _apm_tools_override being stripped even when parent applies an override.
  • Fix em-dash \u2014 in base.py (pre-existing Windows encoding bug, now more visible via this PR).

Review conducted by the APM Expert Panel (Python Architect, Supply Chain Security, DevX UX, CLI Logging, APM CEO). Estimated ~15 lines of additional changes needed.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #869 · ● 20.3M ·

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 microsoft#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 microsoft#844
Fixes microsoft#848
Resolved conflicts in:
- CHANGELOG.md: combined PR microsoft#844/microsoft#848 changes with upstream microsoft#918 changes
- src/apm_cli/policy/parser.py: kept _looks_like_yaml_content pattern from upstream, added (OSError, ValueError) handling
- uv.lock: updated to version 0.10.0
@chaobo8484
Copy link
Copy Markdown
Contributor Author

I have updated the code again based on the Bot's suggestions and optimized the implementation.Please let me know if there are any further issues. Have a wonderful day!

@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 28, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict

Disposition: REQUEST_CHANGES (cursor fix and policy-parser fix are clean; three blocking issues in the target-detection reversion and logging regressions must be addressed first)


Per-persona findings

Python Architect:

This PR bundles three architectural threads across 31 changed files. Thread 1 (cursor adapter) is clean. Thread 2 (target detection) is a major architectural reversion with two new bugs introduced. Thread 3 (marketplace GHES removal) is a deliberate scope reduction.

Before: target-validation class structure

classDiagram
    direction LR
    class parse_target_field {
        <<SharedValidator>>
        +parse(value, source_path) Union[str,List,None]
    }
    class TargetParamType {
        <<ClickParamType>>
        +convert(value, param, ctx)
    }
    class APMPackage {
        +from_apm_yml(path) APMPackage
    }
    class active_targets {
        <<IOBoundary>>
        +__call__(root, explicit_target) List
    }
    TargetParamType ..> parse_target_field : delegates
    APMPackage ..> parse_target_field : delegates
    active_targets ..> APMPackage : consumes target
    class parse_target_field:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading

After: target-validation class structure

classDiagram
    direction LR
    class TargetParamType {
        <<ClickParamType>>
        +convert(value, param, ctx)
        -- inline validation (duplicated)
    }
    class APMPackage {
        +from_apm_yml(path) APMPackage
        -- raw data.get(target) passthrough
    }
    class CopilotClientAdapter {
        +_format_server_config(server_info) dict
    }
    class CursorClientAdapter {
        <<Adapter>>
        +_format_server_config(server_info) dict
        -- super()+transform pattern
    }
    class RefResolver {
        -- no host/token params
        +list_remote_refs(owner_repo) List
        -- hardcoded github.com URL
    }
    class MarketplaceBuilder {
        -- no _host, _host_info, _auth_resolved
        +_fetch_remote_metadata(pkg) Optional
        -- hardcoded raw.githubusercontent.com
    }
    CopilotClientAdapter <|-- CursorClientAdapter
    MarketplaceBuilder *-- RefResolver
    class CursorClientAdapter:::touched
    class APMPackage:::touched
    class RefResolver:::touched
    class MarketplaceBuilder:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading

Execution flow: apm.yml CSV target -> active_targets (AFTER, showing silent failure)

flowchart TD
    A["[FS] APMPackage.from_apm_yml(apm.yml)"] --> B["data.get('target') = 'claude,copilot'"]
    B --> C["[FS] APMPackage(target='claude,copilot') raw string stored"]
    C --> D["[FS] active_targets(root, explicit_target='claude,copilot')"]
    D --> E{isinstance str?}
    E --> F["KNOWN_TARGETS.get('claude,copilot') = None"]
    F --> G["return []"]
    G --> H["phases/targets.py: if ctx.logger and _targets: FALSE"]
    H --> I["[SILENT] No warning emitted, exit 0"]
    style G fill:#ffcccc
    style I fill:#ffcccc
Loading

Design patterns

Design patterns

  • Used in this PR: Delegate-then-transform (Adapter) -- CursorClientAdapter._format_server_config calls super() and transforms the result for Cursor schema; correct application, eliminates ~100-line copy-paste fork.
  • Used in this PR: none (target detection thread) -- parse_target_field shared validator is deleted and replaced by duplicated inline logic in two separate call sites (TargetParamType.convert and raw passthrough in APMPackage.from_apm_yml). This is a regression from the DRY principle with no compensating abstraction.
  • Pragmatic suggestion: restore the shared validator as a module-level function in core/target_detection.py but make it lenient (warn rather than raise on unknown tokens) if the strict behavior was causing issues. The validation and parsing responsibilities should not live in both the CLI layer and the model layer independently.

CLI Logging Expert:

Two signal-level regressions in this PR:

  1. src/apm_cli/install/phases/targets.py -- the if ctx.logger and _targets: guard removes the entire logging block when _targets is empty. Before this PR, the empty branch emitted:

    [!] No project targets resolved -- nothing will be deployed.
        Check 'target:' in apm.yml or use --target.
    

    After: user gets exit 0, no output, no guidance. This is the "silent zero-effect success" anti-pattern called out explicitly in APM's design philosophy.

  2. src/apm_cli/commands/compile/cli.py -- the zero-output compile guard is deleted. Previously, when _files_written == 0, the compile command emitted a warning naming the probable causes. Now logger.success("Compilation completed successfully!") is always called, even when zero files are written. The success symbol on a zero-output run misleads users into thinking compilation did something.

Minor: script_runner.py and script_formatters.py switch from .as_posix() to str(prompt_file). On Windows, str(Path(...)) produces backslash-separated paths in CLI output, which is inconsistent with the rest of APM's cross-platform output. .as_posix() was the correct choice here.


DevX UX Expert:

Three UX violations, one critical:

  1. Critical -- silent zero-deployment: A user writes target: "claude,copilot" in apm.yml (a natural CSV string). APMPackage.from_apm_yml now stores it raw. active_targets() receives "claude,copilot" as a single string, finds no matching profile, returns []. apm install exits 0. Nothing deployed. No message. This is the exact bug [BUG] apm fails to install or compile dependencies #820 fixed. The fix is being reverted without a replacement guard.

  2. Critical -- silent apm.yml parse errors in compile: commands/compile/cli.py wraps APMPackage.from_apm_yml in except Exception: pass. A user with a YAML syntax error in apm.yml now silently falls through to auto-detection instead of seeing "Error in apm.yml: ..." The compile command should fail loudly on malformed config, not silently ignore it.

  3. Asymmetric fallback behavior: active_targets() for an all-unknown explicit list returns [copilot] (surprise fallback to default). active_targets_user_scope() for an all-unknown explicit list returns []. A user who passes --target nonexistent gets Copilot deployed in project scope and nothing deployed in user scope. This is not documented.

The Cursor MCP fix and policy-parser fix are excellent UX improvements on their own.


Supply Chain Security Expert:

Positives: The ${env:GITHUB_TOKEN} substitution in CursorClientAdapter._format_server_config is a direct security improvement -- .cursor/mcp.json is repo-local and could be committed to git; embedding a literal Bearer token there would be a credential commit. The .cursor/ gitignore addition adds a second layer. The (OSError, ValueError) broadening in load_policy correctly handles Windows ValueError on null-byte paths.

Concerns:

  1. RefResolver loses token-authenticated git ls-remote. The new URL pattern https://github.com/{owner_repo}.git runs with GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=echo (no interactive credentials). For users with private marketplace packages on github.com who previously relied on the GITHUB_APM_PAT -> GITHUB_TOKEN chain being injected into the URL, git ls-remote will now fail. The failure is loud (not silent) at the subprocess level, so this is not a security regression, but it is a functional regression for private-repo users.

  2. _resolve_url_source now only accepts https://github.com/ or `(github.com/redacted) prefixes, rejecting all other hosts. This is a scope reduction that hardens the boundary (APM is github.com-only for marketplace). No security regression here; the tightening is appropriate.

  3. Thread safety: _prefetch_metadata now calls self._resolve_github_token() directly before the ThreadPoolExecutor. This is correct timing (token resolved before workers) -- no race introduced.


Auth Expert:

Activated: This PR removes token injection from RefResolver, removes GHES host classification from MarketplaceBuilder, and introduces a new credential reference pattern in the Cursor adapter.

RefResolver change: The host and token constructor parameters are removed. git ls-remote now calls https://github.com/{owner_repo}.git without credentials. Previously, build_https_clone_url(self._host, owner_repo, token=self._token) embedded tokens as x-access-token:{token}@{host}. This was the AuthResolver-sanctioned path for authenticated git operations. The removal breaks the AuthResolver contract ("every remote operation must go through AuthResolver") for ref-resolution operations. The system git credential helper may pick up credentials, but this is environment-dependent and untested.

_resolve_github_token now hardcodes resolver.resolve("github.com"). Previously it used resolver.resolve(self._host). For GITHUB_HOST=corp.ghe.com, the token resolution now queries the github.com host context. In practice, GITHUB_APM_PAT and GITHUB_TOKEN are checked regardless of host, so the behavioral change is minimal for env-var tokens. But the host-classification branch (_host_info.kind) is gone, so GHES-specific behavior (REST API for content fetch) is no longer possible.

Cursor adapter: ${env:GITHUB_TOKEN} is hardcoded. APM's precedence chain is GITHUB_APM_PAT_{ORG} > GITHUB_APM_PAT > GITHUB_TOKEN > GH_TOKEN. A user who authenticates APM with GITHUB_APM_PAT will have Cursor use GITHUB_TOKEN (which may be unset or a weaker token). The env-var reference should document this limitation. Using GITHUB_TOKEN is still the right choice for the .cursor/mcp.json context (Cursor reads env at launch, not APM's resolution chain), but the discrepancy should be noted in the docstring.


OSS Growth Hacker:

Side-channel to CEO:

Cursor fix = concrete conversion win. Cursor is the fastest-growing AI IDE. A broken .cursor/mcp.json schema (type: local is not a valid Cursor type) silently prevents MCP servers from loading in Cursor. Fixing it closes a silent adoption gap that current Cursor users will hit without knowing APM is the cause. The story angle: "APM now generates valid Cursor MCP config out of the box -- install once, works everywhere."

Silent zero-deployment = conversion killer. A new user follows a blog post, writes target: "claude,copilot" in their apm.yml, runs apm install, sees nothing deployed, and churns. They do not file an issue. They do not ask for help. They just stop using APM. The removed "No targets resolved" warning was the one self-help signal that converted a confused user into a "oh, I need YAML list syntax" user. Its removal is a first-run experience regression that will not show up in issues but will show up in churn.

Recommendation to CEO: ship the Cursor fix and policy-parser fix now (high adoption value, zero risk). Gate the target-detection reversion behind explicit CHANGELOG communication and restore the zero-target warning before it lands.


CEO arbitration

The Cursor adapter fix (#844) and the policy-parser fix (#848) are clean, well-tested, and ready to merge. They represent real adoption wins -- especially the Cursor fix, which closes a silent schema incompatibility for a fast-growing IDE segment. The supply-chain specialists and growth hacker agree: ship these.

The target-detection changes are a different story. The PR reverts #820 (shared validator, strict target parsing) without replacing the user-facing safety nets that #820 introduced. The three concrete regressions -- silent zero-deployment from CSV target strings in apm.yml, swallowed apm.yml parse errors in compile, and always-success message when zero files are written -- are not acceptable at the current APM adoption stage. Each one turns a misconfigured-user into a churned user with no recovery path. The removed "No targets resolved" warning was not defensive ceremony; it was the primary first-run recovery surface.

The GHES marketplace removal is a defensible scope decision (github.com-first is a legitimate strategic choice) and is properly documented. The RefResolver token-auth loss is a follow-up concern the team should track.

Strategic call: Restore the three safety nets (zero-target warning, zero-output compile warning, loud apm.yml parse error) before merging. These are 15-20 lines of code and do not conflict with the stated goal of this PR. The Cursor fix stands on its own and should not be held waiting.


Required actions before merge

  1. Restore the zero-target warning in src/apm_cli/install/phases/targets.py. The current if ctx.logger and _targets: guard silences all output when no targets resolve. Re-add the else: branch with ctx.logger.warning(f"No {_scope_label} targets resolved -- nothing will be deployed. Check 'target:' in apm.yml or use --target.") to restore the critical self-help signal for users with misconfigured target fields.

  2. Restore the zero-output compile guard in src/apm_cli/commands/compile/cli.py. The logger.success("Compilation completed successfully!") call should be conditional on files actually being written, as the deleted _files_written > 0 check enforced. Without it, a zero-output compile reports success. Replace the bare logger.success(...) with the previous _files_written-gated logic (or a simplified equivalent).

  3. Do not catch all exceptions from APMPackage.from_apm_yml in compile. The except Exception: pass block silently swallows YAML syntax errors, permission errors, and other actionable failures. Narrow to (FileNotFoundError,) at most, or explicitly log the error before falling through: logger.warning(f"Could not load apm.yml: {e}. Proceeding with auto-detection.").


Optional follow-ups

  • Track whether RefResolver should restore authenticated git ls-remote for private github.com marketplace packages. The current unauthenticated URL will fail for users with private repos who previously relied on GITHUB_APM_PAT being embedded. A follow-up issue citing [BUG] apm fails to install or compile dependencies #820 and [BUG] apm marketplace build fails for GitHub Enterprise Server repositories #1008 would capture this scope clearly.
  • Consider adding a note to the CursorClientAdapter._format_server_config docstring documenting that ${env:GITHUB_TOKEN} is used (not the full APM precedence chain), so users with GITHUB_APM_PAT know to also set GITHUB_TOKEN in their Cursor environment.
  • The active_targets() / active_targets_user_scope() fallback asymmetry (all-unknown list: project-scope returns [copilot], user-scope returns []) should be documented or unified in a follow-up.

Generated by PR Review Panel for issue #869 · ● 1.8M ·

@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-review Trigger the apm-review-panel gh-aw workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants