Skip to main content

MCP Integration

Bundles can include MCP (Model Context Protocol) servers that extend Copilot's capabilities.

Components

ComponentResponsibility
BundleInstallerCalls MCP install/uninstall during bundle lifecycle
McpServerManagerOrchestrates installation, naming, tracking, input merging
McpConfigServiceReads/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["• Add bundle prefix to name\n(prompt-registry:bundleId: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 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

VariableDescription
${bundlePath}Absolute path to bundle directory
${bundleId}Bundle identifier
${bundleVersion}Bundle version
${env:VAR_NAME}Environment variable
${input:id}VS Code input prompt (defined in mcp.inputs)

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

FieldTypeDescription
idstringUnique identifier, referenced as ${input:id} in server config
typepromptString | pickString | commandInput type
descriptionstringLabel shown to the user
passwordbooleanMask the value (for secrets)
defaultstringPre-filled default value
optionsstring[]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 inputs array of mcp.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

  1. Read tracking metadata for bundle's servers
  2. Remove servers from mcp.json
  3. Remove orphaned inputs — any ${input:id} no longer referenced by any remaining server is removed from the inputs array
  4. Update tracking metadata
  5. 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 TypeIdentity FormatExample
Stdiostdio:{command}:{args joined by |}stdio:node:server.js|--port|3000
Remoteremote:{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

  1. Install: First server with identity stays enabled; duplicates disabled
  2. Uninstall: When active server's bundle is removed, remaining duplicates are re-evaluated
  3. 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"
}
}
}
FieldMeaning
pathFull path template to the config file, including the filename. Tokens are resolved by resolvePathTokens.
serversKeyJSON 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.

TokenValue
${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:

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