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["• 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 repository field, 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's repository) is used.
  • The repository is parsed with parseGitHubOwnerRepo (core domain/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​

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

envPlaceholderStyleWritten asHosts
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​

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​