Skip to content

MCP servers as a primitive

When a consumer runs apm install against your package, the dependencies.mcp: block in your apm.yml becomes their MCP server config. No README copy-paste, no per-harness JSON. This page is the producer side of Install MCP servers.

Add a dependencies.mcp: section to your package’s apm.yml:

dependencies:
mcp:
- io.github.github/github-mcp-server

On the consumer’s machine, apm install <your-package> writes this into every detected harness’s MCP config file.

Primitives and targets lists MCP servers as a primitive APM routes per target. Unlike .apm/skills/ or .apm/prompts/, MCP servers do not live as files in your package — they live as declarations in apm.yml. APM materialises them at install time into the harness-specific config file (see the per-harness map in Install MCP servers).

You declare once. At project scope, APM writes .vscode/mcp.json, .cursor/mcp.json, .mcp.json for Claude, .codex/config.toml, and the rest — whichever harnesses the consumer has.

Each entry under dependencies.mcp: (or devDependencies.mcp:) is either a bare string or a mapping. Fields, from src/apm_cli/models/dependency/mcp.py:

FieldRequired whenNotes
namealways (mapping form)Matches ^[a-zA-Z0-9@_][a-zA-Z0-9._@/:=-]{0,127}$.
transportself-definedOne of stdio, http, sse, streamable-http.
commandself-defined stdioSingle binary path; no whitespace (use args).
argsoptionalList for self-defined; dict for registry overlays.
urlself-defined http/sse/streamable-httphttp:// or https:// only.
envoptional, stdioMap of env vars passed to the child process.
headersoptional, remoteMap of HTTP headers. CR/LF rejected.
toolsoptionalAllowlist of tool names. Default ["*"].
versionoptionalPin a registry server version.
registryoptionalfalse = self-defined; URL = custom registry.
packageoptionalnpm, pypi, or oci for registry-resolved servers.

Three forms cover every case:

dependencies:
mcp:
# 1. Registry reference -- resolved from api.mcp.github.com
- io.github.github/github-mcp-server
# 2. Self-defined stdio
- name: filesystem
registry: false
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
# 3. Self-defined remote
- name: linear
registry: false
transport: http
url: https://mcp.linear.app/sse
headers:
Authorization: "Bearer ${LINEAR_TOKEN}"

For each detected harness, APM writes the relevant MCP config file. The full mapping (file path, scope, JSON/TOML schema) is in Install MCP servers. You do not need to know it to author — APM handles the per-target translation.

The consumer can run apm mcp list to confirm the server landed in each runtime they care about.

Treat apm.yml like package.json: it is committed, reviewed, and shipped. Do not embed tokens. Two patterns work:

# Env-var indirection -- resolved by APM or the harness per target
- name: linear
registry: false
transport: http
url: https://mcp.linear.app/sse
headers:
Authorization: "Bearer ${LINEAR_TOKEN}"
# Stdio env -- use ${VAR} for indirection from the installer environment
- name: my-internal
registry: false
transport: stdio
command: my-server
env:
API_TOKEN: "${MY_API_TOKEN}"

Headers and env values are never shell-expanded by APM. For harnesses that support runtime env placeholders (for example VS Code and Kiro), APM preserves the placeholder so the harness resolves it when the server starts or the request is made. For harnesses that require literal values (for example Claude Code and Codex self-defined stdio env), APM resolves ${VAR} from the install process environment, prompts interactively when the terminal is attached, and leaves unresolved placeholders unchanged in non-interactive installs. Keep the real secret in the consumer’s environment (or their secret manager).

The github-mcp-server is a special case: APM injects an Authorization: Bearer <token> header automatically when it writes the Copilot CLI config. See Token injection.

When a registry server marks an env/input variable optional, APM does not generate a prompt or runtime config entry unless a value is already available. See the manifest schema reference for the canonical per-target and required-vs-optional rules.

A self-defined MCP server (registry: false) declared by your package is trusted only when your package is a direct dependency of the consumer. If your package is pulled in transitively, APM warns and skips the MCP entry unless the consumer passes --trust-transitive-mcp. Source: src/apm_cli/integration/mcp_integrator_install.py and src/apm_cli/integration/mcp_integrator.py.

Implications for producers:

  • Registry-resolved servers (form 1 above) flow through transitively without a trust prompt — they are vetted by the registry.
  • Self-defined stdio and remote servers should be reserved for things the consumer would expect from a direct dependency.
  • Document any self-defined MCP server in your README so a transitive consumer knows what they would be trusting.

For the full trust model, see Lifecycle and Security.

  • Whitespace in command: APM does not split on spaces. Put the binary in command and arguments in args. Validation rejects command: "npx -y server" with a fix-it message.
  • Hard-coded paths: command: /Users/me/bin/server works on your laptop and nowhere else. Prefer a binary on PATH (npx, uvx) or a runtime-installed package.
  • url: with non-http(s) scheme: rejected at parse time. WebSocket and file:// are not supported transports.
  • Embedded tokens in headers or env literals: a reviewer will see them. Use ${VAR} indirection.
  • Forgetting devDependencies.mcp:: an MCP server you only need for development (a local mock, a debug bridge) belongs in devDependencies.mcp:. apm pack excludes it; consumers do not get it. See dev-only primitives.