Skip to content

Extract MCPIntegrator: move MCP lifecycle logic out of cli.py #209

@danielmeppiel

Description

@danielmeppiel

Context

Follow-up from #201 (stale MCP server cleanup). The implementation is correct and well-tested, but after a deeper architectural review, the original 4 hardening items are better addressed as part of a structural extraction that solves the root cause: MCP lifecycle logic is ~800+ lines of bare functions scattered inside cli.py (5,025 lines).


Architectural Assessment

The problem

cli.py is a monolith. MCP-related functions alone span ~690 lines (lines 2468–3155), plus orchestration code embedded inside install() and uninstall(). Meanwhile, every other primitive type (prompts, agents, skills, commands, hooks, instructions) has a clean integrator in src/apm_cli/integration/. MCP has all the supporting infrastructure (adapters, registry operations, lockfile tracking) but no orchestration layer — that logic lives as 11 bare _functions in cli.py.

The fix: Extract MCPIntegrator — as a standalone orchestrator, NOT a BaseIntegrator subclass

This is the key design decision. The existing BaseIntegrator pattern is for file-level deployment (copy files, track in deployed_files, handle collisions, resolve links). MCP integration is fundamentally different — it's config-level orchestration (resolve deps, call registry APIs, write runtime configs, track in lockfile). Forcing MCP into BaseIntegrator would mean implementing find_*_files(), copy_*(), sync_integration() methods that don't apply.

MCPIntegrator (NEW — standalone orchestrator)
  ├── uses: MCPServerOperations (registry/operations.py — already exists)
  ├── uses: MCPClientAdapter subclasses (adapters/client/ — already exist)
  ├── uses: LockFile.mcp_servers (deps/lockfile.py — already exists)
  └── owns: lifecycle logic currently scattered in cli.py

What moves into MCPIntegrator

Function in cli.py Becomes Responsibility
_collect_transitive_mcp_deps() MCPIntegrator.collect_transitive() Dependency resolution
_deduplicate_mcp_deps() MCPIntegrator.deduplicate() Dedup logic
_install_mcp_dependencies() MCPIntegrator.install() Main orchestrator
_apply_mcp_overlay() MCPIntegrator._apply_overlay() Server info mutation
_build_self_defined_server_info() MCPIntegrator._build_self_defined_info() Synthetic server factory
_get_mcp_dep_names() MCPIntegrator.get_server_names() Name extraction
_remove_stale_mcp_servers() MCPIntegrator.remove_stale() Stale cleanup
_update_lockfile_mcp_servers() MCPIntegrator.update_lockfile() Lockfile persistence
_detect_runtimes_from_scripts() MCPIntegrator._detect_runtimes() Runtime detection
_filter_available_runtimes() MCPIntegrator._filter_runtimes() Runtime filtering
_install_for_runtime() MCPIntegrator._install_for_runtime() Per-runtime dispatch

11 functions, ~690 lines out of cli.py.

Usage in install() after extraction

mcp = MCPIntegrator()
transitive = mcp.collect_transitive(apm_modules_path, lock_path, trust_transitive_mcp)
all_mcp = mcp.deduplicate(root_mcp_deps, transitive)
mcp_count = mcp.install(all_mcp, runtime, exclude, verbose)

stale = old_mcp_servers - mcp.get_server_names(all_mcp)
if stale:
    mcp.remove_stale(stale, runtime, exclude)
mcp.update_lockfile(mcp.get_server_names(all_mcp))

What NOT to do

  • Don't subclass BaseIntegrator — wrong abstraction for config-level orchestration
  • Don't extract _install_apm_dependencies yet — it's ~900 lines but has heavier coupling to install()/uninstall() control flow. Larger scope, separate PR
  • Don't touch the existing adapters — they're already clean and in the right place. MCPIntegrator uses them

Original hardening items (now addressed naturally by the extraction)

All 4 items from the original issue are resolved as part of this work:

  1. Silent except Exception: pass → Add logger.debug() when moving functions into MCPIntegrator
  2. _collect_descendants cycle guard → Add visited set when moving the transitive expansion
  3. _update_lockfile_mcp_servers double read-modify-writeMCPIntegrator.update_lockfile() can accept the lockfile object directly instead of re-reading from disk
  4. Extract to dedicated module → This IS the extraction

Impact

Metric Before After
cli.py lines ~5,025 ~4,335
MCP testability Must mock CLI context Direct unit tests on MCPIntegrator
MCP code location 11 bare functions in cli.py Single class, single file
Behavior change Zero

Priority

Medium — not a bug fix, but a maintainability improvement that unblocks cleaner MCP feature work. Good candidate for a contributor familiar with the integrator pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementDeprecated: use type/feature. Kept for issue history; will be removed in milestone 0.10.0.good first issueGood for newcomers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions