Skip to content

Plugin MCP Server installation support #216

@danielmeppiel

Description

@danielmeppiel

Plan: Plugin MCP Server Installation

TL;DR

Enable plugins that ship MCP servers (via mcpServers in plugin.json or .mcp.json) to have those servers deployed through APM's MCP pipeline to VS Code, Copilot CLI, and Codex. Uses three layers: extraction from plugin format → conversion to MCPDependency → deployment via existing pipeline. Trust model: auto-trust self-defined MCPs from direct dependencies (depth=1); transitive still requires --trust-transitive-mcp.


Phase 1: MCP Extraction from Plugins

Step 1 — _extract_mcp_servers() in plugin_parser.py

New function that resolves plugin MCP server definitions into a normalized dict[str, dict].

Process manifest["mcpServers"] by type (per Claude Code spec: string|array|object):

  • string → read that file path relative to plugin root, parse JSON, extract mcpServers key
  • list → read each file path, merge (last-wins on name conflict)
  • dict → use directly as inline server definitions

When mcpServers is absent and .mcp.json exists at plugin root → read it as default (matches Claude Code auto-discovery behavior so plugins work out of the box).

Security: skip symlinks, validate JSON, log warnings for missing/unreadable files. Return empty dict on failure (non-fatal).

${CLAUDE_PLUGIN_ROOT}: Replace with absolute path to plugin_path in extracted configs. Log info when substitution occurs. (Most plugin MCPs use global commands like npx; this handles the edge case.)

Step 2 — _mcp_servers_to_apm_deps() in plugin_parser.py

New function that converts raw MCP server configs to dependencies.mcp format (list of dicts consumable by MCPDependency.from_dict()).

Transport inference:

  • command present → transport: "stdio", copy command, args
  • url present → transport: "http" (or use type field if present for sse/streamable-http), copy url
  • Neither → skip with warning (invalid MCP config)

All entries get: registry: false (self-defined, not registry lookups).

Field mapping:

Raw config key MCPDependency field
(dict key) name
command command
args args
env env
url url
headers headers
type (if valid transport) transport
tools tools

Step 3 — Wire extraction into synthesize_apm_yml_from_plugin()

  • After _map_plugin_artifacts(), call _extract_mcp_servers(plugin_path, manifest)_mcp_servers_to_apm_deps().
  • Store result in manifest under _mcp_deps key (internal, prefixed with underscore).
  • Modify _generate_apm_yml(): if manifest.get('_mcp_deps') is non-empty, emit dependencies.mcp section.

Step 4 — Keep .mcp.json pass-through

The existing .mcp.json.apm/.mcp.json copy in _map_plugin_artifacts() stays unchanged. It's artifact preservation and doesn't conflict with the new pipeline.


Phase 2: Trust Direct Dependencies' Self-Defined MCPs

Step 5 — Modify collect_transitive() in mcp_integrator.py

No signature changes needed. Internal logic change only.

Current behavior: Self-defined MCPs from ALL packages are skipped unless trust_private=True.

New behavior: Build direct_paths: set from lockfile entries with depth == 1. When iterating packages, check is_direct = apm_yml_path.resolve() in direct_paths. Auto-trust self-defined MCPs from direct deps. Transitive deps (depth > 1) still require trust_private=True.

Pseudocode:

# After reading lockfile and building locked_paths
direct_paths = set()
if lockfile is not None:
    for dep in lockfile.get_all_dependencies():
        if dep.depth == 1 and dep.repo_url:
            yml_path = apm_modules_dir / dep.repo_url / "apm.yml"
            direct_paths.add(yml_path.resolve())

# In the iteration loop
is_direct = apm_yml_path.resolve() in direct_paths
for dep in mcp:
    if dep.is_self_defined:
        if is_direct:
            _rich_info(f"Trusting direct dependency MCP '{dep.name}' from '{pkg.name}'")
        elif trust_private:
            _rich_info(f"Trusting transitive MCP '{dep.name}' (--trust-transitive-mcp)")
        else:
            _rich_warning(f"Skipping transitive self-defined MCP '{dep.name}' from '{pkg.name}'")
            continue
    collected.append(dep)

Fallback: No lockfile available → all packages treated as transitive (conservative). This only happens if someone deletes the lockfile and re-runs — _install_apm_dependencies() creates the lockfile before collect_transitive() runs.


Phase 3: Tests

Step 6 — Extraction tests in test_plugin_parser.py

New test class TestExtractMCPServers:

  • test_mcpservers_inline_object — dict in manifest → extracted directly
  • test_mcpservers_string_path — file path → reads file, extracts servers
  • test_mcpservers_array_paths — multiple file paths → merges, last-wins
  • test_default_mcp_json — no mcpServers field, but .mcp.json exists → auto-discovered
  • test_github_mcp_json_fallback.github/mcp.json discovered when no root .mcp.json
  • test_manifest_wins_over_defaultmcpServers field takes precedence over .mcp.json file
  • test_missing_file_graceful — string path pointing to nonexistent file → empty dict, warning
  • test_symlink_skipped — symlinked file → skipped
  • test_empty_manifest — no mcpServers and no .mcp.json → empty dict

New test class TestMCPServersToDeps:

  • test_stdio_servercommand present → transport=stdio, registry=false
  • test_http_serverurl present → transport=http, registry=false
  • test_mixed_servers — both types in one config
  • test_env_and_args_passthrough
  • test_plugin_root_substitution${CLAUDE_PLUGIN_ROOT} replaced
  • test_invalid_server_skipped — no command or url → skipped with warning

New test in TestGenerateAPMYml:

  • test_mcp_deps_in_generated_yml_mcp_deps in manifest → dependencies.mcp in output

Step 7 — Trust model tests in test_transitive_mcp.py

New tests in TestCollectTransitiveMCPDeps:

  • test_direct_dep_self_defined_auto_trusted — depth=1 package with self-defined MCP → collected without flag
  • test_transitive_dep_self_defined_still_skipped — depth=2 package with self-defined MCP → skipped without flag
  • test_transitive_dep_trusted_with_flag — depth=2 + trust_private=True → collected
  • test_no_lockfile_conservative — no lockfile → all self-defined skipped (conservative)

Phase 4: Documentation

Step 8 — Update docs

  • Update docs/ markdown files covering MCP installation to document plugin MCP support
  • Document that direct deps' self-defined MCPs are auto-trusted
  • Document mcpServers field support in plugin.json

Step 9 — README drift check

  • Check if README mentions plugin MCP behavior; if so, propose update for user approval

Relevant Files

  • src/apm_cli/deps/plugin_parser.py — Add _extract_mcp_servers(), _mcp_servers_to_apm_deps(), modify synthesize_apm_yml_from_plugin() and _generate_apm_yml()
  • src/apm_cli/integration/mcp_integrator.py — Modify collect_transitive() internal logic (no API change)
  • src/apm_cli/models/apm_package.py — No changes needed (MCPDependency.from_dict already handles all fields)
  • src/apm_cli/cli.py — No changes needed (pipeline already wired)
  • tests/unit/test_plugin_parser.py — Add TestExtractMCPServers, TestMCPServersToDeps, extend TestGenerateAPMYml
  • tests/unit/test_transitive_mcp.py — Add direct-dep trust tests

Verification

  1. Run uv run pytest tests/unit/test_plugin_parser.py -v — all new extraction/conversion tests pass
  2. Run uv run pytest tests/unit/test_transitive_mcp.py -v — all new trust tests pass
  3. Run uv run pytest tests/ -x --ignore=tests/test_codex_docker_args_fix.py -q — full suite passes, no regressions
  4. Manual E2E: Create a test plugin with .mcp.json containing a stdio server. Run apm install. Verify the server appears in .vscode/mcp.json or ~/.copilot/mcp-config.json.

Decisions

  • Trust model: "direct deps trusted, transitive require flag" — universal rule using lockfile depth, no model changes needed
  • mcpServers supports string|array|object per Claude Code spec (not just string|object)
  • .mcp.json pass-through to .apm/ kept for artifact preservation (doesn't affect pipeline)
  • ${CLAUDE_PLUGIN_ROOT} resolved to absolute plugin_path at extraction time
  • No changes to MCPDependency model, CLI flags, or adapter code

Scope

  • In scope: Plugin MCP extraction, apm.yml injection, direct-dep trust, tests, docs
  • Out of scope: LSP servers, plugin dependency resolution, new CLI flags, adapter changes

Metadata

Metadata

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