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_default — mcpServers 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_server — command present → transport=stdio, registry=false
test_http_server — url 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
- Run
uv run pytest tests/unit/test_plugin_parser.py -v — all new extraction/conversion tests pass
- Run
uv run pytest tests/unit/test_transitive_mcp.py -v — all new trust tests pass
- Run
uv run pytest tests/ -x --ignore=tests/test_codex_docker_args_fix.py -q — full suite passes, no regressions
- 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
Plan: Plugin MCP Server Installation
TL;DR
Enable plugins that ship MCP servers (via
mcpServersin 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()inplugin_parser.pyNew 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, extractmcpServerskeylist→ read each file path, merge (last-wins on name conflict)dict→ use directly as inline server definitionsWhen
mcpServersis absent and.mcp.jsonexists 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 toplugin_pathin extracted configs. Log info when substitution occurs. (Most plugin MCPs use global commands likenpx; this handles the edge case.)Step 2 —
_mcp_servers_to_apm_deps()inplugin_parser.pyNew function that converts raw MCP server configs to
dependencies.mcpformat (list of dicts consumable byMCPDependency.from_dict()).Transport inference:
commandpresent →transport: "stdio", copycommand,argsurlpresent →transport: "http"(or usetypefield if present forsse/streamable-http), copyurlAll entries get:
registry: false(self-defined, not registry lookups).Field mapping:
namecommandcommandargsargsenvenvurlurlheadersheaderstype(if valid transport)transporttoolstoolsStep 3 — Wire extraction into
synthesize_apm_yml_from_plugin()_map_plugin_artifacts(), call_extract_mcp_servers(plugin_path, manifest)→_mcp_servers_to_apm_deps()._mcp_depskey (internal, prefixed with underscore)._generate_apm_yml(): ifmanifest.get('_mcp_deps')is non-empty, emitdependencies.mcpsection.Step 4 — Keep
.mcp.jsonpass-throughThe existing
.mcp.json→.apm/.mcp.jsoncopy 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()inmcp_integrator.pyNo 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: setfrom lockfile entries withdepth == 1. When iterating packages, checkis_direct = apm_yml_path.resolve() in direct_paths. Auto-trust self-defined MCPs from direct deps. Transitive deps (depth > 1) still requiretrust_private=True.Pseudocode:
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 beforecollect_transitive()runs.Phase 3: Tests
Step 6 — Extraction tests in
test_plugin_parser.pyNew test class
TestExtractMCPServers:test_mcpservers_inline_object— dict in manifest → extracted directlytest_mcpservers_string_path— file path → reads file, extracts serverstest_mcpservers_array_paths— multiple file paths → merges, last-winstest_default_mcp_json— nomcpServersfield, but.mcp.jsonexists → auto-discoveredtest_github_mcp_json_fallback—.github/mcp.jsondiscovered when no root.mcp.jsontest_manifest_wins_over_default—mcpServersfield takes precedence over.mcp.jsonfiletest_missing_file_graceful— string path pointing to nonexistent file → empty dict, warningtest_symlink_skipped— symlinked file → skippedtest_empty_manifest— nomcpServersand no.mcp.json→ empty dictNew test class
TestMCPServersToDeps:test_stdio_server—commandpresent → transport=stdio, registry=falsetest_http_server—urlpresent → transport=http, registry=falsetest_mixed_servers— both types in one configtest_env_and_args_passthroughtest_plugin_root_substitution—${CLAUDE_PLUGIN_ROOT}replacedtest_invalid_server_skipped— no command or url → skipped with warningNew test in
TestGenerateAPMYml:test_mcp_deps_in_generated_yml—_mcp_depsin manifest →dependencies.mcpin outputStep 7 — Trust model tests in
test_transitive_mcp.pyNew tests in
TestCollectTransitiveMCPDeps:test_direct_dep_self_defined_auto_trusted— depth=1 package with self-defined MCP → collected without flagtest_transitive_dep_self_defined_still_skipped— depth=2 package with self-defined MCP → skipped without flagtest_transitive_dep_trusted_with_flag— depth=2 +trust_private=True→ collectedtest_no_lockfile_conservative— no lockfile → all self-defined skipped (conservative)Phase 4: Documentation
Step 8 — Update docs
docs/markdown files covering MCP installation to document plugin MCP supportmcpServersfield support in plugin.jsonStep 9 — README drift check
Relevant Files
src/apm_cli/deps/plugin_parser.py— Add_extract_mcp_servers(),_mcp_servers_to_apm_deps(), modifysynthesize_apm_yml_from_plugin()and_generate_apm_yml()src/apm_cli/integration/mcp_integrator.py— Modifycollect_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— AddTestExtractMCPServers,TestMCPServersToDeps, extendTestGenerateAPMYmltests/unit/test_transitive_mcp.py— Add direct-dep trust testsVerification
uv run pytest tests/unit/test_plugin_parser.py -v— all new extraction/conversion tests passuv run pytest tests/unit/test_transitive_mcp.py -v— all new trust tests passuv run pytest tests/ -x --ignore=tests/test_codex_docker_args_fix.py -q— full suite passes, no regressions.mcp.jsoncontaining a stdio server. Runapm install. Verify the server appears in.vscode/mcp.jsonor~/.copilot/mcp-config.json.Decisions
depth, no model changes neededmcpServerssupportsstring|array|objectper Claude Code spec (not juststring|object).mcp.jsonpass-through to.apm/kept for artifact preservation (doesn't affect pipeline)${CLAUDE_PLUGIN_ROOT}resolved to absolute plugin_path at extraction timeScope