MCP Integration
Bundles can include MCP (Model Context Protocol) servers that extend Copilot's capabilities.
Components
| Component | Responsibility |
|---|---|
| BundleInstaller | Calls MCP install/uninstall during bundle lifecycle |
| McpServerManager | Orchestrates installation, naming, tracking, input merging |
| McpConfigService | Reads/writes VS Code's mcp.json, merges/cleans inputs |
Installation Flow
graph TD
A["Bundle Install"]
B["BundleInstaller.installMcpServers()"]
C["McpServerManager.installServers() or\ninstallServersToWorkspace()"]
D["• Name the server\n(ai-primitives-hub:owner:repo:manifestId:server-name)\n• Substitute variables\n• mergeInputs() — deduplicate by id\n• Write servers + inputs to mcp.json\n• Create tracking metadata"]
E["MCP servers + inputs available to Copilot"]
A --> B
B --> C
C --> D
D --> E
Server Names
Each server is written to the host config as ai-primitives-hub:<owner>:<repo>:<manifest id>:<server name>, built by resolveMcpServerKey in @ai-primitives-hub/core. For example, server gateway of collection sample-mcp published from https://github.com/acme-org/sample-collections becomes ai-primitives-hub:acme-org:sample-collections:sample-mcp:gateway.
- The name has no version, so it stays the same across bundle updates. Hosts keep per-server settings (trust, enabled tools,
tools:references in agent files) under this name. - The repository makes the name globally unique: collection ids are only checked for duplicates within one repository.
- The repository comes from the deployment manifest's
repositoryfield, written by the collection build, so a release gets the same name whichever source it is installed from (hub, GitHub source, mirror, Artifactory). When the manifest has no usable value (empty in older builds, or a local path), the repository of the install source (the bundle'srepository) is used. - The repository is parsed with
parseGitHubOwnerRepo(coredomain/hub/validate.ts), so the usual URL forms (HTTPS,.git, SSH,owner/repo) give the same name. Owner and repository are lowercased. - With no GitHub repository at all (local folder sources, other git hosts), the name is
ai-primitives-hub:<manifest id>:<server name>. - The name depends only on the bundle's own manifest and source, never on what else is installed. There is no fallback name, because a name chosen by install order would change again when the other bundle is removed.
- Installing a bundle replaces the servers of any earlier version of the same bundle (same bundle id once the version suffix is removed), in user, workspace and repository scope. This covers updates and installing a newer version on top, including from a hub and from a GitHub source of the same repository, which produce the same bundle id.
- A different bundle id that produces the same name in one scope (for example a mirror or local copy of the same release) gets
Conflicts detected: <name>: its MCP servers are not written, the rest of the bundle still installs, and the first bundle's server is left untouched. - The collection id and the publishing repository together are the public name of a collection's servers: renaming either renames the servers.
- Servers installed by older versions are named
prompt-registry:<bundle id>:<server name>. They keep that name until the bundle is updated or uninstalled; uninstall finds them through the tracking metadata, which records the owning bundle id.
Server Types
Stdio Servers (Local Process)
mcpServers:
server-name:
type: stdio # Optional (default)
command: string # Required
args: string[] # Optional
env: Record<string, string> # Optional
envFile: string # Optional - path to .env file
disabled: boolean # Optional (default: false)
description: string # Optional
Remote Servers (HTTP/SSE)
mcpServers:
api-server:
type: http # Required: 'http' or 'sse'
url: string # Required - supports http://, https://, unix://, pipe://
headers: Record<string, string> # Optional - for authentication
disabled: boolean # Optional
description: string # Optional
Variable Substitution
| Variable | Description |
|---|---|
${bundlePath} | Absolute path to bundle directory |
${bundleId} | Bundle identifier |
${bundleVersion} | Bundle version |
${env:VAR_NAME} | Environment variable, resolved by the host at runtime (see below) |
${input:id} | VS Code input prompt (defined in mcp.inputs) |
${bundlePath}, ${bundleId} and ${bundleVersion} are substituted at install time. ${env:VAR_NAME} is never resolved at install time, because that would write secrets (tokens, passwords) to the host's MCP config file in cleartext. The placeholder is written instead, translated to the host's runtime syntax by the mcpConfig.envPlaceholderStyle field in default-layouts.json:
envPlaceholderStyle | Written as | Hosts |
|---|---|---|
prefixed (default) | ${env:VAR_NAME} | VS Code, Cursor, Windsurf, and any host without an explicit style |
bare | ${VAR_NAME} | Kiro, Kiro CLI, Claude Code |
A :-default suffix is kept as-is (${env:VAR:-x} becomes ${VAR:-x} on bare hosts). Kiro only expands variables listed in its "Mcp Approved Env Vars" setting.
Input Definitions
Collections can define mcp.inputs to declare secrets or configurable values that VS Code will prompt the user for. These follow the VS Code mcp.json inputs spec.
Schema
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier, referenced as ${input:id} in server config |
type | promptString | pickString | command | Input type |
description | string | Label shown to the user |
password | boolean | Mask the value (for secrets) |
default | string | Pre-filled default value |
options | string[] | Choices for pickString type |
Example
mcp:
inputs:
- id: serviceToken
type: promptString
description: "Service access token (not stored)"
password: true
- id: serviceUser
type: promptString
description: "Service username"
- id: servicePassword
type: promptString
description: "Service password or app password"
password: true
items:
server-a:
type: stdio
command: podman
args:
- run
- -e
- "TOKEN=${input:serviceToken}"
- my-mcp-server-a:latest
server-b:
type: stdio
command: podman
args:
- run
- -e
- "USERNAME=${input:serviceUser}"
- -e
- "PASSWORD=${input:servicePassword}"
- my-mcp-server-b:latest
Merge Behaviour
When a collection is installed, its mcp.inputs are merged into the existing mcp.json:
- Inputs are deduplicated by
id— the existing definition takes priority over incoming ones - This allows multiple collections to share the same input without conflict
- Inputs are added to the top-level
inputsarray ofmcp.json
Deployment Manifest Format
When a collection is published as a GitHub release, lib/bin/generate-manifest.js converts the nested MCP section from .collection.yml into the deployment manifest. The generated deployment-manifest format uses top-level mcpServers and mcpInputs fields:
mcpInputs:
- id: serviceToken
type: promptString
description: "Service access token (not stored)"
password: true
mcpServers:
server-a:
type: stdio
command: podman
args:
- run
- -e
- "TOKEN=${input:serviceToken}"
- my-mcp-server-a:latest
The nested mcp.inputs and mcp.items fields belong to the source collection format. Older deployment manifests may omit mcpInputs and contain only the top-level mcpServers field. The GitHub adapter reads the top-level deployment-manifest fields.
Example
mcpServers:
custom-server:
command: node
args:
- "${bundlePath}/servers/custom.js"
env:
BUNDLE_ID: "${bundleId}"
API_KEY: "${env:MY_API_KEY}"
description: Custom operations
Uninstallation
- Read tracking metadata for bundle's servers
- Remove servers from
mcp.json - Remove orphaned inputs — any
${input:id}no longer referenced by any remaining server is removed from theinputsarray - Update tracking metadata
- Atomic operations with backup/rollback
Shared inputs are preserved: if another installed bundle's server still references an input, it is kept.
Duplicate Detection Algorithm
When multiple bundles define the same MCP server, duplicates are automatically detected and disabled.
Server Identity Computation
computeServerIdentity(config: McpServerConfig): string {
if (isRemoteServerConfig(config)) {
return `remote:${config.url}`;
} else {
const argsStr = config.args?.join('|') || '';
return `stdio:${config.command}:${argsStr}`;
}
}
| Server Type | Identity Format | Example |
|---|---|---|
| Stdio | stdio:{command}:{args joined by |} | stdio:node:server.js|--port|3000 |
| Remote | remote:{url} | remote:https://api.example.com/mcp |
Detection Flow
graph TD
A["After server installation"]
B["detectAndDisableDuplicates()"]
C["For each server in mcp.json"]
D{"Identity already seen?"}
E["Record identity → server mapping"]
F["Mark as disabled\nAdd description: 'Duplicate of X'"]
G["Write updated config"]
A --> B
B --> C
C --> D
D -->|No| E
D -->|Yes & enabled| F
E --> C
F --> C
C -->|Done| G
Lifecycle Behavior
- Install: First server with identity stays enabled; duplicates disabled
- Uninstall: When active server's bundle is removed, remaining duplicates are re-evaluated
- Invariant: At least one server per identity remains active until all bundles are removed
Type Guards
// Discriminate server types
isStdioServerConfig(config) // true if has 'command', no 'url'
isRemoteServerConfig(config) // true if has 'url' and type is 'http'|'sse'
Config File Locations
IDE-specific MCP paths and the JSON root key live in
packages/infra/src/writers/default-layouts.json, inside each target's per-scope
mcpConfig entry, resolved by McpConfigLocator. To add or change an IDE, edit that
file rather than the locator.
mcpConfig sits alongside baseDir inside user and repository, so it layers the
same way as the primitive layout fields:
"kiro": {
"user": {
"baseDir": "${HOME}/.kiro",
"kindRoutes": { "prompts/": "steering/" },
"mcpConfig": {
"path": "${HOME}/.kiro/settings/mcp.json",
"serversKey": "mcpServers"
}
},
"repository": {
"baseDir": "${workspaceRoot}/.kiro",
"kindRoutes": { "prompts/": "steering/" },
"mcpConfig": {
"path": "${workspaceRoot}/.kiro/settings/mcp.json",
"serversKey": "mcpServers"
}
}
}
| Field | Meaning |
|---|---|
path | Full path template to the config file, including the filename. Tokens are resolved by resolvePathTokens. |
serversKey | JSON root key for the server map: servers (VS Code) or mcpServers (all other known IDEs). |
Path Tokens
One resolver handles all tokens, and an unknown or empty token raises
UnresolvedPathTokenError rather than resolving to an empty string or surviving into
the path. That matters: an unresolved token would otherwise create a directory
literally named ${...}, and an empty ${HOME} would turn
${HOME}/.kiro/mcp.json into an absolute /.kiro/mcp.json.
| Token | Value |
|---|---|
${HOME} | User home directory. |
${workspaceRoot} | Absolute workspace root. Required in every repository path. |
${vscodeUserDir} | VS Code user data User directory, resolved from the running host. |
${vscodeUserDir} exists because VS Code is the only supported host whose config
directory is neither HOME-relative nor identical across platforms: it follows each OS's
app-data convention, the Code segment varies by variant (Insiders, Cursor, Windsurf,
Kiro), and portable installs, --user-data-dir and remote sessions move it entirely.
Scope Independence
A missing mcpConfig means the IDE has no MCP config file at that scope. It is
never inherited from the other scope, unlike baseDir, where repository falls back to
user. Windsurf and Copilot CLI have no workspace-level MCP file, so inheriting the
user entry would make a repository-scope install write into the user's home config.
Note the consequence: two fields in the same object have opposite inheritance rules.
resolveLayoutFromLayers does repository ?? user; resolveMcpLayoutConfig
deliberately does not.
On-disk Syntax
Files are always read with the JSONC parser, so comments and trailing commas are
accepted regardless of host. There is deliberately no format field: writes
currently go through JSON.stringify, so nothing would branch on it.
Known gap: because writes replace the whole file, comments and formatting in a
user's mcp.json are lost on write. Comment-preserving writes would use
jsonc-parser's modify/applyEdits to touch only changed keys, and a format field
belongs here only once that exists to consume it.
Known Limitation: VS Code Profiles
For VS Code and VS Code Insiders the user-level path resolves through
${vscodeUserDir}, which comes from context.globalStorageUri
(<userDataDir>/User/mcp.json).
This always resolves to the default profile. VS Code supports per-profile MCP
configuration, where a non-default profile's file lives at
<userDataDir>/User/profiles/<profileId>/mcp.json. A user-scope install performed while a
non-default profile is active therefore writes to a file the active profile does not read —
the server is written successfully but never appears. Workspace-scope installs are
unaffected, since .vscode/mcp.json is profile-independent.
This is not fixable with the current extension API. There is no supported way to obtain the active profile's directory:
- microsoft/vscode#160466 — requests a
profile-aware
globalStorageUri; closed as not planned. - microsoft/vscode#211890 — requests a Profiles API; closed as not planned.
The profile ID cannot be expressed as a path template either, since it is neither
HOME-relative nor knowable at authoring time. Confining resolution to the single
${vscodeUserDir} token keeps the fix a one-function change if VS Code ever ships an API.
The long-term fix is vscode.lm.registerMcpServerDefinitionProvider,
which lets the extension contribute MCP servers programmatically and hands storage and
profile scoping to VS Code, removing the need to write mcp.json for VS Code at all. That
only covers VS Code, so file-based writing remains for Kiro, Windsurf, Claude Code and
Copilot CLI.
See Also
- Installation Flow — Bundle installation
- Author Guide: Collection Schema — MCP in manifests