-
Notifications
You must be signed in to change notification settings - Fork 36
Description
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, extractmcpServerskeylist→ 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:
commandpresent →transport: "stdio", copycommand,argsurlpresent →transport: "http"(or usetypefield if present forsse/streamable-http), copyurl- 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_depskey (internal, prefixed with underscore). - Modify
_generate_apm_yml(): ifmanifest.get('_mcp_deps')is non-empty, emitdependencies.mcpsection.
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 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 dict
New 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 warning
New test in TestGenerateAPMYml:
test_mcp_deps_in_generated_yml—_mcp_depsin manifest →dependencies.mcpin 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 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
- Update
docs/markdown files covering MCP installation to document plugin MCP support - Document that direct deps' self-defined MCPs are auto-trusted
- Document
mcpServersfield 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(), 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 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.jsoncontaining a stdio server. Runapm install. Verify the server appears in.vscode/mcp.jsonor~/.copilot/mcp-config.json.
Decisions
- Trust model: "direct deps trusted, transitive require flag" — universal rule using lockfile
depth, no model changes needed mcpServerssupportsstring|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 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