Skip to content

MCP integration layer: scale adapter abstraction so per-runtime branches stop multiplying #772

@danielmeppiel

Description

@danielmeppiel

Problem

APM supports a growing set of install targets (Copilot CLI, Codex, Claude Code, VS Code, Cursor, OpenCode, with more on the way), but MCP-server lifecycle support is not uniform across them and the integration layer doesn't scale.

Surfaced concretely while reviewing #655 (Claude Code MCP target). The adapter itself (in src/apm_cli/adapters/client/) was a clean ~200-line addition, but wiring it into mcp_integrator.py and friends required threading per-runtime branches through several modules. Adding a hypothetical 7th MCP target today touches ~10 files and ~100–270 lines of glue.

Where the coupling lives

Adapter layer (good — O(1) per new target)

  • src/apm_cli/adapters/client/{copilot,vscode,cursor,opencode,codex,claude}.py — each adapter owns its config path, format translation, and read/write
  • src/apm_cli/adapters/client/factory.py — single dict lookup
  • Adding a target here is one new file plus one factory line

Integration layer (bad — O(n) coupling per target)

  1. src/apm_cli/integration/mcp_integrator.py::remove_stale() — already 5 hand-written if/elif blocks (~136 lines on main, lines 477–612), one per runtime, each duplicating: open file → parse runtime-specific format → find stale names → delete → write back → log. Bypasses the adapter abstraction entirely (does not call adapter.get_current_config() / adapter.update_config()).
  2. src/apm_cli/registry/operations.py::_get_installed_server_ids() — similar per-runtime if/elif chain re-implementing file I/O with hardcoded format knowledge.
  3. src/apm_cli/core/conflict_detector.py::get_existing_server_configs() — per-runtime branches (elif "copilot" in adapter_class_name, etc.) for the same reason.
  4. Multiple per-runtime constants and detection lists in mcp_integrator.py (WORKSPACE_SCOPED_MCP_RUNTIMES, _detect_runtimes, fallback lists) that need updating per target.

The root cause is the same in all four places: integration code re-implements format knowledge instead of delegating to the adapter.

What "not widely supported" means in practice

  • Some apm install targets have full MCP install + uninstall + stale-cleanup support (Copilot CLI, VS Code).
  • Others have partial support that silently no-ops when the host config dir is absent (Cursor, OpenCode, now Claude Code project-scope).
  • Behaviors diverge in subtle ways (e.g., feat: MCP install target for Claude Code (project + user scope) #655 introduced mcp_install_scope as adapter-attached state — no other adapter uses runtime-mutable state).
  • Adding a new IDE/CLI target to APM should not require the contributor to learn the per-runtime branch in 3+ integration modules.

Proposed direction

Lift the duck-typed file I/O out of mcp_integrator.py and into the adapter contract. Concretely:

  1. Extend MCPClientAdapter (base.py) with two methods:
    • remove_servers(self, names: Set[str]) -> List[str] — adapter reads its own config, removes named servers, writes back, returns removed names.
    • list_server_ids(self) -> Set[str] — replaces the per-runtime branches in _get_installed_server_ids.
  2. Reduce mcp_integrator.remove_stale() to a polymorphic loop:
    for runtime in target_runtimes:
        adapter = ClientFactory.create_client(runtime)
        removed = adapter.remove_servers(expanded_stale)
        _log_removed(runtime, removed)
    ~30 lines instead of ~270.
  3. Reduce _get_installed_server_ids and conflict_detector similarly — they call adapter.list_server_ids() instead of branching.
  4. Formalize scope handling so feat: MCP install target for Claude Code (project + user scope) #655's mcp_install_scope monkey-patch becomes a constructor parameter or a ClientFactory.create_client(scope=...) kwarg. Most adapters can ignore it.
  5. Unify the silent-skip-on-missing-dir behavior across Cursor, OpenCode, Claude Code into an explicit "not opted in" return value, so install summaries don't lie about what was configured (the symptom from feat: MCP install target for Claude Code (project + user scope) #655 review item Integrate copilot runtime #2).

After this refactor, adding MCP target #7 should be: 1 adapter file + 1 factory line. That's it.

Out of scope for this issue

  • The adapter inheritance hierarchy (Cursor/OpenCode/Claude all inherit from CopilotClientAdapter) — works, low priority to refactor.
  • TOML vs JSON format handling in Codex — already encapsulated in the Codex adapter.

References

  • PR feat: MCP install target for Claude Code (project + user scope) #655 review for the concrete example that motivated this issue.
  • Existing pattern in src/apm_cli/integration/mcp_integrator.py:477-612 (5 hand-written runtime blocks).
  • .github/instructions/integrators.instructions.md — APM already articulates the "one base, many file types" + "pay only for what you touch" principles for the file-level integrators. The same principles should apply to MCP integration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions