diff --git a/README.md b/README.md index dbfabd8..194509d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ clients: | `allagents plugin list` | List available plugins | | `allagents skills add ` | Add a skill from a repo | | `allagents skills list` | List skills and status | +| `allagents mcp add ` | Add an MCP server and sync to clients | +| `allagents mcp list` | List workspace MCP servers | | `allagents workspace status` | Show workspace state | | `allagents self update` | Update AllAgents CLI | diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 15255fe..41584f7 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -186,3 +186,95 @@ After enabling, the skill is removed from `disabledSkills` and sync is run to re :::tip `allagents skills add` is a shorthand for `allagents plugin skills add`. ::: + +## MCP Commands + +```bash +allagents mcp add [options] +allagents mcp remove +allagents mcp list +allagents mcp get +allagents mcp update [--offline] +``` + +Manage MCP servers at the workspace level. Servers are persisted in a top-level `mcpServers:` field in `workspace.yaml` (parallel to `mcpProxy:`) and synced to all configured clients that support project-scoped MCP (`claude`, `codex`, `vscode`, `copilot`). + +### mcp add + +Add a new MCP server to `workspace.yaml` and immediately sync it to all configured clients. + +| Flag | Description | +|------|-------------| +| `--transport ` | Transport type: `http` or `stdio` (auto-detected from URL by default) | +| `--arg ` | Argument for the stdio command (repeatable) | +| `-e, --env ` | Environment variable for stdio transport (repeatable) | +| `--header ` | HTTP header for http transport (repeatable) | +| `--client ` | Comma-separated list of clients that should receive this server (default: all project-scoped clients) | +| `-f, --force` | Replace an existing server with the same name | + +**Transport auto-detection:** if `` starts with `http://` or `https://`, http transport is selected; otherwise stdio is selected. Passing `--transport stdio` with a URL, or `--transport http` with a non-URL command, is rejected. + +```bash +# HTTP server +allagents mcp add deepwiki https://mcp.deepwiki.com/mcp + +# HTTP server with headers and client filter +allagents mcp add internal https://mcp.internal.corp --header Authorization=Bearer-token --client claude,copilot + +# stdio server with args and env vars +allagents mcp add gh-server npx --arg=-y --arg=@modelcontextprotocol/server-github -e GH_TOKEN=ghp_xxx + +# Replace an existing server (update workflow) +allagents mcp add deepwiki https://new.example.com --force +``` + +### mcp remove + +Remove a server from `workspace.yaml` and unsync it from all clients. Only servers AllAgents added are removed; pre-existing user-managed servers in client MCP configs are preserved. + +```bash +allagents mcp remove deepwiki +``` + +### mcp list + +List all MCP servers defined in `workspace.yaml`. + +```bash +allagents mcp list +``` + +### mcp get + +Print the `workspace.yaml` definition for a specific server as YAML. + +```bash +allagents mcp get deepwiki +``` + +Exits with status 1 if the server is not defined in `workspace.yaml`. + +### mcp update + +Re-sync MCP servers only, without touching skills, agents, hooks, or other plugin artifacts. Useful when you've edited `workspace.yaml`'s `mcpServers:` block manually and want to push the changes to clients without running a full workspace sync. + +| Flag | Description | +|------|-------------| +| `--offline` | Use cached plugins without fetching from remote marketplaces | + +```bash +allagents mcp update +allagents mcp update --offline +``` + +To modify a server definition, use `allagents mcp add ... --force`. + +### Ownership model + +AllAgents only tracks MCP servers it added. This means: + +- `mcp add` marks the server as AllAgents-owned in `.allagents/sync-state.json`. +- `mcp remove` only removes servers from client MCP configs that AllAgents originally added. User-managed servers (added manually to `.mcp.json`, `.vscode/mcp.json`, etc.) are never touched. +- If you manually add a server to a client config first, and then run `mcp add` with the same name, the existing user-managed entry is left alone (AllAgents will skip it with a warning). + +See [CLAUDE.md — MCP Server Sync](https://github.com/EntityProcess/allagents/blob/main/CLAUDE.md) for the full ownership rule. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 0ef3a3e..ee3cf86 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -162,6 +162,49 @@ When multiple repositories (or multiple client paths within the same repo) conta This differs from [plugin skill deduplication](/docs/guides/plugins/#duplicate-skill-handling), which keeps all copies and renames them for uniqueness. Repository skills are an index of pointers, so only the best source is kept. +## MCP Servers + +The optional top-level `mcpServers` field defines MCP servers managed directly by the workspace. Servers defined here are synced to all configured project-scoped MCP clients (`claude`, `codex`, `vscode`, `copilot`). + +```yaml +mcpServers: + deepwiki: + type: http + url: https://mcp.deepwiki.com/mcp + headers: + Authorization: Bearer ${DEEPWIKI_TOKEN} + + gh-server: + type: stdio + command: npx + args: + - -y + - '@modelcontextprotocol/server-github' + env: + GH_TOKEN: ghp_xxx + + # Per-server client filter — only sync to specific clients + claude-only: + type: http + url: https://mcp.example.com + clients: + - claude +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `type` | No | `http` or `stdio` (inferred from presence of `url` vs `command` if omitted) | +| `url` | http only | HTTP URL for the MCP server | +| `headers` | No | HTTP headers map (http transport only) | +| `command` | stdio only | Executable for the stdio command | +| `args` | No | Command arguments array (stdio transport only) | +| `env` | No | Environment variables map (stdio transport only) | +| `clients` | No | Subset of project-scoped clients that should receive this server. Defaults to all configured clients. | + +Manage these entries declaratively in `workspace.yaml`, or via the [`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level servers override any plugin-supplied server with the same name (with a warning). + +Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.copilot/mcp-config.json`, `.codex/config.toml`) are never touched. + ## MCP Proxy The optional `mcpProxy` section rewrites HTTP MCP servers to stdio via [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) for clients that need it. See the [MCP Proxy guide](/docs/guides/mcp-proxy/) for details. diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..51520c4 --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,406 @@ +import { dump } from 'js-yaml'; +import { + array, + command, + flag, + multioption, + option, + optional, + positional, + string, +} from 'cmd-ts'; +import { + addWorkspaceMcpServer, + buildMcpServerConfigFromFlags, + getWorkspaceMcpServer, + listWorkspaceMcpServers, + parseKeyValuePairs, + removeWorkspaceMcpServer, +} from '../../core/mcp-servers.js'; +import { syncMcpOnly } from '../../core/mcp-sync.js'; +import { ClientTypeSchema, type ClientType, type McpServerConfig } from '../../models/workspace-config.js'; +import { isJsonMode, jsonOutput } from '../json-output.js'; +import { buildDescription, conciseSubcommands } from '../help.js'; +import { formatMcpResult } from '../format-sync.js'; +import { + mcpAddMeta, + mcpGetMeta, + mcpListMeta, + mcpRemoveMeta, + mcpUpdateMeta, +} from '../metadata/mcp.js'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function parseClientFilter(input: string): ClientType[] { + const items = input + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const result: ClientType[] = []; + for (const item of items) { + const parsed = ClientTypeSchema.safeParse(item); + if (!parsed.success) { + throw new Error( + `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, + ); + } + result.push(parsed.data); + } + return result; +} + +function exitWithError(command: string, error: string): never { + if (isJsonMode()) { + jsonOutput({ success: false, command, error }); + } else { + console.error(`Error: ${error}`); + } + process.exit(1); +} + +/** + * Shared flag parsing for `mcp add` / `mcp update`. Exits with a user-friendly + * error if any flag is invalid. + */ +function buildConfigFromAddFlags( + commandName: string, + commandOrUrl: string, + transport: string | undefined, + args: string[], + env: string[], + header: string[], + client: string | undefined, +): McpServerConfig { + if (transport && transport !== 'http' && transport !== 'stdio') { + exitWithError(commandName, `Invalid transport '${transport}'. Expected 'http' or 'stdio'.`); + } + + const envResult = parseKeyValuePairs(env, '-e/--env'); + if ('error' in envResult) exitWithError(commandName, envResult.error); + + const headerResult = parseKeyValuePairs(header, '--header'); + if ('error' in headerResult) exitWithError(commandName, headerResult.error); + + let clients: ClientType[] | undefined; + if (client) { + try { + clients = parseClientFilter(client); + } catch (e) { + exitWithError(commandName, e instanceof Error ? e.message : String(e)); + } + } + + const buildOpts: Parameters[0] = { + commandOrUrl, + args, + env: envResult.values, + headers: headerResult.values, + }; + if (transport) buildOpts.transport = transport as 'http' | 'stdio'; + if (clients) buildOpts.clients = clients; + + const built = buildMcpServerConfigFromFlags(buildOpts); + if ('error' in built) exitWithError(commandName, built.error); + return built.config; +} + +/** + * Run MCP-only sync after a mutation and print per-scope results. Always runs + * offline because the mutation only affects local workspace.yaml and does not + * require refreshing plugins from remote marketplaces. + */ +async function runPostMutationSync( + commandName: string, + successMessage: string, + jsonExtra: Record, +): Promise { + const syncResult = await syncMcpOnly(process.cwd(), { offline: true }); + if (!syncResult.success) { + exitWithError(commandName, syncResult.error ?? 'MCP sync failed'); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: commandName, + data: { ...jsonExtra, mcpResults: syncResult.mcpResults }, + }); + return; + } + + console.log(successMessage); + for (const [scope, result] of Object.entries(syncResult.mcpResults)) { + if (!result) continue; + const lines = formatMcpResult(result, scope); + if (lines.length > 0) { + console.log(''); + for (const line of lines) console.log(line); + } + } + for (const warning of syncResult.warnings) { + console.log(` \u26A0 ${warning}`); + } +} + +function serverToDisplay(name: string, config: McpServerConfig): string[] { + const lines: string[] = [`${name}:`]; + const isHttp = 'url' in config; + lines.push(` transport: ${isHttp ? 'http' : 'stdio'}`); + if (isHttp) { + lines.push(` url: ${config.url}`); + if (config.headers && Object.keys(config.headers).length > 0) { + lines.push(' headers:'); + for (const [k, v] of Object.entries(config.headers)) { + lines.push(` ${k}: ${v}`); + } + } + } else { + lines.push(` command: ${config.command}`); + if (config.args && config.args.length > 0) { + lines.push(` args: ${JSON.stringify(config.args)}`); + } + if (config.env && Object.keys(config.env).length > 0) { + lines.push(' env:'); + for (const [k, v] of Object.entries(config.env)) { + lines.push(` ${k}: ${v}`); + } + } + } + if (config.clients && config.clients.length > 0) { + lines.push(` clients: [${config.clients.join(', ')}]`); + } + return lines; +} + +// ============================================================================= +// mcp add +// ============================================================================= + +const addArgs = { + name: positional({ type: string, displayName: 'name' }), + commandOrUrl: positional({ type: string, displayName: 'commandOrUrl' }), + transport: option({ + type: optional(string), + long: 'transport', + description: "Transport: 'http' or 'stdio' (auto-detected from URL if omitted)", + }), + args: multioption({ + type: array(string), + long: 'arg', + description: 'Argument for stdio command (repeatable)', + }), + env: multioption({ + type: array(string), + long: 'env', + short: 'e', + description: 'Environment variable KEY=VALUE (repeatable)', + }), + header: multioption({ + type: array(string), + long: 'header', + description: 'HTTP header KEY=VALUE (repeatable)', + }), + client: option({ + type: optional(string), + long: 'client', + description: 'Comma-separated list of client filters', + }), +}; + +const mcpAddCmd = command({ + name: 'add', + description: buildDescription(mcpAddMeta), + args: { + ...addArgs, + force: flag({ long: 'force', short: 'f', description: 'Replace an existing server with the same name' }), + }, + handler: async ({ name, commandOrUrl, transport, args, env, header, client, force }) => { + const config = buildConfigFromAddFlags( + 'mcp add', + commandOrUrl, + transport, + args, + env, + header, + client, + ); + const addResult = await addWorkspaceMcpServer(name, config, process.cwd(), force); + if (!addResult.success) exitWithError('mcp add', addResult.error ?? 'Unknown error'); + await runPostMutationSync('mcp add', `\u2713 Added MCP server '${name}' to workspace.yaml`, { + name, + config: addResult.config, + }); + }, +}); + +// ============================================================================= +// mcp remove +// ============================================================================= + +const mcpRemoveCmd = command({ + name: 'remove', + description: buildDescription(mcpRemoveMeta), + args: { + name: positional({ type: string, displayName: 'name' }), + }, + handler: async ({ name }) => { + const removeResult = await removeWorkspaceMcpServer(name, process.cwd()); + if (!removeResult.success) exitWithError('mcp remove', removeResult.error ?? 'Unknown error'); + await runPostMutationSync( + 'mcp remove', + `\u2713 Removed MCP server '${name}' from workspace.yaml`, + { name }, + ); + }, +}); + +// ============================================================================= +// mcp list +// ============================================================================= + +const mcpListCmd = command({ + name: 'list', + description: buildDescription(mcpListMeta), + args: {}, + handler: async () => { + let servers: Record; + try { + servers = await listWorkspaceMcpServers(process.cwd()); + } catch (e) { + exitWithError('mcp list', e instanceof Error ? e.message : String(e)); + } + const names = Object.keys(servers); + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'mcp list', + data: { servers, total: names.length }, + }); + return; + } + + if (names.length === 0) { + console.log('No MCP servers defined in workspace.yaml.'); + console.log(''); + console.log('Add one with:'); + console.log(' allagents mcp add '); + return; + } + + console.log(`MCP servers (${names.length}):`); + console.log(''); + for (const name of names) { + const config = servers[name]; + if (!config) continue; + for (const line of serverToDisplay(name, config)) { + console.log(` ${line}`); + } + console.log(''); + } + }, +}); + +// ============================================================================= +// mcp get +// ============================================================================= + +const mcpGetCmd = command({ + name: 'get', + description: buildDescription(mcpGetMeta), + args: { + name: positional({ type: string, displayName: 'name' }), + }, + handler: async ({ name }) => { + let config: McpServerConfig | null; + try { + config = await getWorkspaceMcpServer(name, process.cwd()); + } catch (e) { + exitWithError('mcp get', e instanceof Error ? e.message : String(e)); + } + if (!config) { + exitWithError('mcp get', `MCP server '${name}' not found in workspace.yaml`); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'mcp get', + data: { name, config }, + }); + return; + } + + console.log(dump({ [name]: config }, { lineWidth: -1 }).trimEnd()); + }, +}); + +// ============================================================================= +// mcp update +// ============================================================================= + +const mcpUpdateCmd = command({ + name: 'update', + description: buildDescription(mcpUpdateMeta), + args: { + offline: flag({ long: 'offline', description: 'Use cached plugins without fetching from remote' }), + }, + handler: async ({ offline }) => { + const result = await syncMcpOnly(process.cwd(), { offline }); + if (!result.success) { + exitWithError('mcp update', result.error ?? 'MCP sync failed'); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'mcp update', + data: { mcpResults: result.mcpResults, warnings: result.warnings }, + }); + return; + } + + const hasAnyChanges = Object.values(result.mcpResults).some( + (r) => r && (r.added > 0 || r.overwritten > 0 || r.removed > 0 || r.skipped > 0), + ); + + if (!hasAnyChanges) { + console.log('No MCP server changes.'); + } else { + for (const [scope, mcpResult] of Object.entries(result.mcpResults)) { + if (!mcpResult) continue; + const lines = formatMcpResult(mcpResult, scope); + if (lines.length > 0) { + for (const line of lines) console.log(line); + console.log(''); + } + } + } + + if (result.warnings.length > 0) { + console.log('Warnings:'); + for (const warning of result.warnings) { + console.log(` \u26A0 ${warning}`); + } + } + }, +}); + +// ============================================================================= +// mcp group +// ============================================================================= + +export const mcpCmd = conciseSubcommands({ + name: 'mcp', + description: 'Manage MCP servers for AI clients', + cmds: { + add: mcpAddCmd, + remove: mcpRemoveCmd, + list: mcpListCmd, + get: mcpGetCmd, + update: mcpUpdateCmd, + }, +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 3ace267..1477382 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import { run } from 'cmd-ts'; import { conciseSubcommands } from './help.js'; import { workspaceCmd, syncCmd } from './commands/workspace.js'; import { pluginCmd } from './commands/plugin.js'; +import { mcpCmd } from './commands/mcp.js'; import { selfCmd } from './commands/self.js'; import { skillsCmd } from './commands/plugin-skills.js'; import { extractJsonFlag, setJsonMode } from './json-output.js'; @@ -21,6 +22,7 @@ const app = conciseSubcommands({ update: syncCmd, workspace: workspaceCmd, plugin: pluginCmd, + mcp: mcpCmd, self: selfCmd, skills: skillsCmd, }, diff --git a/src/cli/metadata/mcp.ts b/src/cli/metadata/mcp.ts new file mode 100644 index 0000000..be5c6ac --- /dev/null +++ b/src/cli/metadata/mcp.ts @@ -0,0 +1,79 @@ +import type { AgentCommandMeta } from '../help.js'; + +export const mcpAddMeta: AgentCommandMeta = { + command: 'mcp add', + description: 'Add an MCP server to workspace.yaml and sync to clients', + whenToUse: + 'When adding a new MCP server that you want AllAgents to manage and sync to all configured clients', + examples: [ + 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp', + 'allagents mcp add my-server npx --arg=-y --arg=@my/mcp-server', + 'allagents mcp add gh-api npx -e GH_TOKEN=abc123 --arg=-y --arg=@modelcontextprotocol/server-github', + 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot', + ], + expectedOutput: + 'Adds the server to workspace.yaml and syncs it to all configured clients. Exit 0 on success, 1 on failure.', + positionals: [ + { name: 'name', type: 'string', required: true, description: 'Server name (unique within workspace.yaml)' }, + { + name: 'commandOrUrl', + type: 'string', + required: true, + description: + 'HTTP URL (http://, https://) for http transport, or a command for stdio transport', + }, + ], + options: [ + { flag: '--transport', type: 'string', description: "Transport type: 'http' or 'stdio' (auto-detected from URL by default)" }, + { flag: '--arg', type: 'string', description: 'Argument to pass to the stdio command (repeatable)' }, + { flag: '--env', short: '-e', type: 'string', description: 'Environment variable KEY=VALUE for stdio transport (repeatable)' }, + { flag: '--header', type: 'string', description: 'HTTP header KEY=VALUE for http transport (repeatable)' }, + { flag: '--client', type: 'string', description: "Comma-separated list of clients that should receive this server (default: all project-scoped clients)" }, + { flag: '--force', short: '-f', type: 'boolean', description: 'Replace an existing server with the same name' }, + ], +}; + +export const mcpRemoveMeta: AgentCommandMeta = { + command: 'mcp remove', + description: 'Remove an MCP server from workspace.yaml and all clients', + whenToUse: 'When you no longer need an MCP server that AllAgents added', + examples: ['allagents mcp remove deepwiki'], + expectedOutput: + 'Removes the server from workspace.yaml and unsyncs it from all configured clients. Exit 0 on success, 1 if the server is not defined in workspace.yaml.', + positionals: [ + { name: 'name', type: 'string', required: true, description: 'Server name to remove' }, + ], +}; + +export const mcpListMeta: AgentCommandMeta = { + command: 'mcp list', + description: 'List MCP servers defined in workspace.yaml', + whenToUse: 'To inspect MCP servers AllAgents is managing at the workspace level', + examples: ['allagents mcp list'], + expectedOutput: + 'Prints a table of workspace-defined MCP servers with transport, target, and client filter. Exit 0 on success.', +}; + +export const mcpGetMeta: AgentCommandMeta = { + command: 'mcp get', + description: 'Show the workspace definition for an MCP server', + whenToUse: 'To see how an MCP server is configured in workspace.yaml', + examples: ['allagents mcp get deepwiki'], + expectedOutput: 'Prints the server config (YAML). Exit 0 on success, 1 if not found.', + positionals: [ + { name: 'name', type: 'string', required: true, description: 'Server name' }, + ], +}; + +export const mcpUpdateMeta: AgentCommandMeta = { + command: 'mcp update', + description: 'Sync MCP servers only, without touching other artifacts', + whenToUse: + "When you've edited workspace.yaml's mcpServers block or plugin .mcp.json files and want to re-sync only MCP servers without re-running the full workspace sync. To modify a server definition use 'mcp add --force'.", + examples: ['allagents mcp update', 'allagents mcp update --offline'], + expectedOutput: + 'Runs the MCP portion of sync for all project-scoped clients. Prints per-scope added/updated/removed counts. Exit 0 on success, 1 on failure.', + options: [ + { flag: '--offline', type: 'boolean', description: 'Use cached plugins without fetching from remote' }, + ], +}; diff --git a/src/core/mcp-servers.ts b/src/core/mcp-servers.ts new file mode 100644 index 0000000..5845a04 --- /dev/null +++ b/src/core/mcp-servers.ts @@ -0,0 +1,232 @@ +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { dump, load } from 'js-yaml'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import type { + ClientType, + McpServerConfig, + WorkspaceConfig, +} from '../models/workspace-config.js'; +import { McpServerConfigSchema } from '../models/workspace-config.js'; +import { ensureWorkspace } from './workspace-modify.js'; + +/** + * Result of add/remove/update operations on workspace mcpServers. + */ +export interface McpServerModifyResult { + success: boolean; + error?: string; + /** Normalized server config that was written (for add/update) */ + config?: McpServerConfig; +} + +function getConfigPath(workspacePath: string): string { + return join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); +} + +async function readConfig(configPath: string): Promise { + const content = await readFile(configPath, 'utf-8'); + return load(content) as WorkspaceConfig; +} + +async function writeConfig(configPath: string, config: WorkspaceConfig): Promise { + await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); +} + +/** + * Validate a server config via the McpServerConfigSchema. Returns a + * user-friendly error on failure. + */ +function validateServerConfig( + config: unknown, +): { valid: true; data: McpServerConfig } | { valid: false; error: string } { + const result = McpServerConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`); + return { valid: false, error: `Invalid MCP server config:\n${issues.join('\n')}` }; + } + return { valid: true, data: result.data }; +} + +/** + * Add a new MCP server entry to workspace.yaml. Fails if a server with the + * given name already exists (unless `force` is set). + */ +export async function addWorkspaceMcpServer( + name: string, + config: McpServerConfig, + workspacePath: string = process.cwd(), + force = false, +): Promise { + const validation = validateServerConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + try { + await ensureWorkspace(workspacePath); + const configPath = getConfigPath(workspacePath); + const workspaceConfig = await readConfig(configPath); + workspaceConfig.mcpServers ??= {}; + + if (workspaceConfig.mcpServers[name] && !force) { + return { + success: false, + error: `MCP server '${name}' already exists in workspace.yaml. Pass --force to replace it.`, + }; + } + + workspaceConfig.mcpServers[name] = validation.data; + await writeConfig(configPath, workspaceConfig); + return { success: true, config: validation.data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Remove an MCP server entry from workspace.yaml. Returns success: false if + * the server is not defined in workspace.yaml (it may still exist in a plugin). + */ +export async function removeWorkspaceMcpServer( + name: string, + workspacePath: string = process.cwd(), +): Promise { + const configPath = getConfigPath(workspacePath); + if (!existsSync(configPath)) { + return { + success: false, + error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found in ${workspacePath}`, + }; + } + + try { + const workspaceConfig = await readConfig(configPath); + if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { + return { + success: false, + error: `MCP server '${name}' not found in workspace.yaml`, + }; + } + + delete workspaceConfig.mcpServers[name]; + if (Object.keys(workspaceConfig.mcpServers).length === 0) { + workspaceConfig.mcpServers = undefined; + } + + await writeConfig(configPath, workspaceConfig); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Read an MCP server entry from workspace.yaml. Returns null if it is not + * defined there (it may still be defined by a plugin). + */ +export async function getWorkspaceMcpServer( + name: string, + workspacePath: string = process.cwd(), +): Promise { + const configPath = getConfigPath(workspacePath); + if (!existsSync(configPath)) return null; + const workspaceConfig = await readConfig(configPath); + return workspaceConfig.mcpServers?.[name] ?? null; +} + +/** + * List all MCP servers defined in workspace.yaml. + */ +export async function listWorkspaceMcpServers( + workspacePath: string = process.cwd(), +): Promise> { + const configPath = getConfigPath(workspacePath); + if (!existsSync(configPath)) return {}; + const workspaceConfig = await readConfig(configPath); + return workspaceConfig.mcpServers ?? {}; +} + +/** + * Build an McpServerConfig from CLI flags. Transport defaults: + * - If commandOrUrl starts with http(s)://, treat as HTTP + * - Otherwise treat as stdio command + */ +export function buildMcpServerConfigFromFlags(options: { + commandOrUrl: string; + transport?: 'http' | 'stdio'; + args?: string[]; + env?: Record; + headers?: Record; + clients?: ClientType[]; +}): { config: McpServerConfig } | { error: string } { + const { commandOrUrl, args, env, headers, clients } = options; + const transport = + options.transport ?? (/^https?:\/\//i.test(commandOrUrl) ? 'http' : 'stdio'); + + if (transport === 'http') { + if (!/^https?:\/\//i.test(commandOrUrl)) { + return { + error: `HTTP transport requires a URL starting with http:// or https:// (got '${commandOrUrl}')`, + }; + } + if (args && args.length > 0) { + return { error: '--arg is not supported for HTTP transport' }; + } + if (env && Object.keys(env).length > 0) { + return { error: '-e/--env is not supported for HTTP transport' }; + } + const config: McpServerConfig = { + type: 'http', + url: commandOrUrl, + ...(headers && Object.keys(headers).length > 0 && { headers }), + ...(clients && clients.length > 0 && { clients }), + }; + return { config }; + } + + if (options.transport === 'stdio' && /^https?:\/\//i.test(commandOrUrl)) { + return { + error: `stdio transport requires a command, not a URL (got '${commandOrUrl}')`, + }; + } + if (headers && Object.keys(headers).length > 0) { + return { error: '--header is not supported for stdio transport' }; + } + const config: McpServerConfig = { + type: 'stdio', + command: commandOrUrl, + ...(args && args.length > 0 && { args }), + ...(env && Object.keys(env).length > 0 && { env }), + ...(clients && clients.length > 0 && { clients }), + }; + return { config }; +} + +/** + * Parse KEY=VALUE strings into a record. Returns an error if any entry is + * malformed. + */ +export function parseKeyValuePairs( + pairs: string[], + flagName: string, +): { values: Record } | { error: string } { + const values: Record = {}; + for (const pair of pairs) { + const eqIdx = pair.indexOf('='); + if (eqIdx <= 0) { + return { error: `Invalid ${flagName}: '${pair}' (expected KEY=VALUE)` }; + } + const key = pair.slice(0, eqIdx); + const value = pair.slice(eqIdx + 1); + values[key] = value; + } + return { values }; +} diff --git a/src/core/mcp-sync.ts b/src/core/mcp-sync.ts new file mode 100644 index 0000000..7f53995 --- /dev/null +++ b/src/core/mcp-sync.ts @@ -0,0 +1,296 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import type { ClientType, WorkspaceConfig } from '../models/workspace-config.js'; +import type { SyncState } from '../models/sync-state.js'; +import { + buildPluginSyncPlans, + collectSyncClients, + seedFetchCacheFromMarketplaces, + validateAllPlugins, + type ValidatedPlugin, +} from './sync.js'; +import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; +import { syncClaudeMcpConfig } from './claude-mcp.js'; +import { syncCodexProjectMcpConfig } from './codex-mcp.js'; +import { applyMcpProxy, ensureProxyMetadata, getProxyMetadataPath } from './mcp-proxy.js'; +import { + getPreviouslySyncedMcpServers, + loadSyncState, + saveSyncState, + type McpScope, +} from './sync-state.js'; +import { ensureMarketplacesRegistered } from './marketplace.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { migrateWorkspaceSkillsV1toV2 } from './workspace-modify.js'; + +/** + * Clients that support project-scoped MCP server sync. + */ +const PROJECT_MCP_CLIENTS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'vscode', + 'copilot', + 'universal', +]); + +/** + * Result of running the MCP sync pipeline across all scopes. + */ +export interface SyncMcpServersResult { + /** Per-scope merge results keyed by McpScope ('vscode' | 'claude' | 'codex' | 'copilot') */ + mcpResults: Partial>; + /** Warnings from collection, proxy, or per-client sync */ + warnings: string[]; + /** Per-scope tracked server names, for persisting into sync state */ + trackedServers: Partial>; +} + +interface McpSyncSpec { + client: ClientType; + scope: McpScope; + configPath: string; + syncFn: ( + validatedPlugins: ValidatedPlugin[], + options: { + dryRun?: boolean; + configPath?: string; + force?: boolean; + trackedServers?: string[]; + serverOverrides?: Map; + }, + ) => McpMergeResult; +} + +function buildSyncSpecs(workspacePath: string): McpSyncSpec[] { + return [ + { + client: 'vscode', + scope: 'vscode', + configPath: join(workspacePath, '.vscode', 'mcp.json'), + syncFn: syncVscodeMcpConfig, + }, + { + client: 'claude', + scope: 'claude', + configPath: join(workspacePath, '.mcp.json'), + syncFn: syncClaudeMcpConfig, + }, + { + client: 'codex', + scope: 'codex', + configPath: join(workspacePath, '.codex', 'config.toml'), + syncFn: syncCodexProjectMcpConfig, + }, + { + client: 'copilot', + scope: 'copilot', + configPath: join(workspacePath, '.copilot', 'mcp-config.json'), + syncFn: syncClaudeMcpConfig, + }, + ]; +} + +/** + * Sync MCP servers from plugins and workspace config into every configured + * project-scoped client. Respects the ownership model (trackedServers) so + * user-managed entries are never overwritten. + * + * This is the single source of truth for MCP-only sync behavior. It is + * invoked both from the full `syncWorkspace` pipeline and from the + * standalone `allagents mcp update` command. + */ +export function syncMcpServers( + workspacePath: string, + validPlugins: ValidatedPlugin[], + config: WorkspaceConfig, + previousState: SyncState | null, + syncClients: ClientType[], + options: { dryRun?: boolean } = {}, +): SyncMcpServersResult { + const { dryRun = false } = options; + const warnings: string[] = []; + const mcpResults: Partial> = {}; + const trackedServers: Partial> = {}; + + // Prepare MCP proxy transform if configured + const mcpProxyConfig = config.mcpProxy; + let proxyMetadataPath: string | undefined; + if (mcpProxyConfig) { + if (!dryRun) { + ensureProxyMetadata(); + } + proxyMetadataPath = getProxyMetadataPath(); + } + + // Emit collection warnings once (e.g. workspace overriding plugin server) + // rather than repeating them for every client scope. + let collectWarningsEmitted = false; + function getServersForClient(client: ClientType): Map { + const { servers, warnings: collectWarnings } = collectMcpServers( + validPlugins, + config.mcpServers, + client, + ); + if (!collectWarningsEmitted) { + warnings.push(...collectWarnings); + collectWarningsEmitted = true; + } + if (mcpProxyConfig && proxyMetadataPath) { + return applyMcpProxy(servers, client, mcpProxyConfig, proxyMetadataPath); + } + return servers; + } + + const specs = buildSyncSpecs(workspacePath); + for (const spec of specs) { + if (!syncClients.includes(spec.client)) continue; + const tracked = getPreviouslySyncedMcpServers(previousState, spec.scope); + const serverOverrides = getServersForClient(spec.client); + const result = spec.syncFn(validPlugins, { + dryRun, + force: false, + configPath: spec.configPath, + trackedServers: tracked, + serverOverrides, + }); + if (result.warnings.length > 0) { + warnings.push(...result.warnings); + } + mcpResults[spec.scope] = result; + trackedServers[spec.scope] = result.trackedServers; + } + + // Warn about configured clients that don't support project-scoped MCP sync + // when there are servers that would otherwise have been synced. + const anyServers = collectMcpServers(validPlugins, config.mcpServers).servers; + if (anyServers.size > 0) { + for (const client of syncClients) { + if (!PROJECT_MCP_CLIENTS.has(client)) { + warnings.push(`MCP servers not synced for ${client} (not supported at project scope)`); + } + } + } + + return { mcpResults, warnings, trackedServers }; +} + +/** + * Result of the standalone `mcp update` pipeline. + */ +export interface SyncMcpOnlyResult { + success: boolean; + mcpResults: Partial>; + warnings: string[]; + error?: string; +} + +/** + * Standalone MCP-only sync for the `allagents mcp update` command. + * + * Does the minimal amount of work needed to sync MCP servers: + * - Parse workspace config + * - Validate plugins (so `.mcp.json` can be discovered) + * - Run the MCP sync pipeline + * - Persist `mcpServers` tracking into sync state (preserving other fields) + * + * Does NOT copy plugin files, run managed repos, generate agent files, or + * touch native plugin installs. + */ +export async function syncMcpOnly( + workspacePath: string = process.cwd(), + options: { offline?: boolean; dryRun?: boolean } = {}, +): Promise { + const { offline = false, dryRun = false } = options; + await migrateWorkspaceSkillsV1toV2(workspacePath); + + const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); + if (!existsSync(configPath)) { + return { + success: false, + mcpResults: {}, + warnings: [], + error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found in ${workspacePath}\n Run 'allagents workspace init' to create a new workspace`, + }; + } + + let config: WorkspaceConfig; + try { + config = await parseWorkspaceConfig(configPath); + } catch (error) { + return { + success: false, + mcpResults: {}, + warnings: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + const warnings: string[] = []; + + const { plans, warnings: planWarnings } = buildPluginSyncPlans( + config.plugins, + config.clients, + 'project', + ); + warnings.push(...planWarnings); + + const filteredPlans = plans.filter( + (plan) => plan.clients.length > 0 || plan.nativeClients.length > 0, + ); + const syncClients = collectSyncClients(config.clients, filteredPlans); + + // Pre-register marketplaces so that plugin validation can resolve them. + // Skip in offline mode to avoid network calls. + if (!offline) { + const marketplaceResults = await ensureMarketplacesRegistered( + filteredPlans.map((plan) => plan.source), + ); + await seedFetchCacheFromMarketplaces(marketplaceResults); + } + + // Validate plugins so we can read their .mcp.json files + const validatedPlugins = await validateAllPlugins(filteredPlans, workspacePath, offline); + const validPlugins = validatedPlugins.filter((v): v is ValidatedPlugin => v.success); + warnings.push( + ...validatedPlugins.filter((v) => !v.success).map((v) => `${v.plugin}: ${v.error} (skipped)`), + ); + + const previousState = await loadSyncState(workspacePath); + + const syncResult = syncMcpServers( + workspacePath, + validPlugins, + config, + previousState, + syncClients, + { dryRun }, + ); + + warnings.push(...syncResult.warnings); + + // Persist mcpServers tracking into sync state (preserve other fields) + if (!dryRun) { + const hasMcpChanges = Object.values(syncResult.mcpResults).some( + (r) => r && (r.added > 0 || r.overwritten > 0 || r.removed > 0), + ); + if (hasMcpChanges) { + await saveSyncState(workspacePath, { + files: (previousState?.files as Record) ?? {}, + mcpServers: syncResult.trackedServers, + ...(previousState?.nativePlugins && { nativePlugins: previousState.nativePlugins }), + ...(previousState?.vscodeWorkspaceHash && { vscodeWorkspaceHash: previousState.vscodeWorkspaceHash }), + ...(previousState?.vscodeWorkspaceRepos && { vscodeWorkspaceRepos: previousState.vscodeWorkspaceRepos }), + ...(previousState?.skillsIndex && { skillsIndex: previousState.skillsIndex }), + }); + } + } + + return { + success: true, + mcpResults: syncResult.mcpResults, + warnings, + }; +} diff --git a/src/core/sync.ts b/src/core/sync.ts index ad3a404..225827d 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -68,12 +68,13 @@ import { reconcileVscodeWorkspaceFolders, } from './vscode-workspace.js'; import { setRepositories, updateRepositories, migrateWorkspaceSkillsV1toV2 } from './workspace-modify.js'; -import { syncVscodeMcpConfig, collectMcpServers } from './vscode-mcp.js'; -import { applyMcpProxy, ensureProxyMetadata, getProxyMetadataPath } from './mcp-proxy.js'; +import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; import type { McpMergeResult } from './vscode-mcp.js'; -import { syncCodexMcpServers, syncCodexProjectMcpConfig } from './codex-mcp.js'; +import { applyMcpProxy, ensureProxyMetadata, getProxyMetadataPath } from './mcp-proxy.js'; +import { syncCodexMcpServers } from './codex-mcp.js'; import { syncClaudeMcpConfig, syncClaudeMcpServersViaCli } from './claude-mcp.js'; import { getCopilotMcpConfigPath } from './copilot-mcp.js'; +import { syncMcpServers as runMcpSync } from './mcp-sync.js'; import { getNativeClient, mergeNativeSyncResults, type NativeSyncResult } from './native/index.js'; import { Stopwatch } from '../utils/stopwatch.js'; import { processManagedRepos } from './managed-repos.js'; @@ -282,7 +283,7 @@ export interface ValidatedPlugin { pluginSkillsConfig?: PluginSkillsConfig; } -interface PluginSyncPlan { +export interface PluginSyncPlan { source: string; clients: ClientType[]; /** Clients that should use native install for this plugin */ @@ -358,7 +359,7 @@ function attachNativeClientContext(result: NativeSyncResult, clientType: ClientT }; } -function collectSyncClients( +export function collectSyncClients( clientEntries: ClientEntry[], plans: PluginSyncPlan[], ): ClientType[] { @@ -1072,7 +1073,7 @@ async function validatePlugin( * Build plugin sync plans with effective clients per plugin. * Effective clients are plugin.clients when provided, otherwise workspace clients. */ -function buildPluginSyncPlans( +export function buildPluginSyncPlans( plugins: PluginEntry[], clientEntries: ClientEntry[], scope: 'user' | 'project', @@ -1139,7 +1140,7 @@ function buildPluginSyncPlans( * @param offline - Skip fetching from remote and use cached version * @returns Array of validation results */ -async function validateAllPlugins( +export async function validateAllPlugins( plans: PluginSyncPlan[], workspacePath: string, offline: boolean, @@ -1999,116 +2000,22 @@ export async function syncWorkspace( } } - // MCP Proxy: prepare transform if configured - const mcpProxyConfig = config.mcpProxy; - let proxyMetadataPath: string | undefined; - let proxyBaseServers: Map | undefined; - if (mcpProxyConfig) { - if (!dryRun) { - ensureProxyMetadata(); - } - proxyMetadataPath = getProxyMetadataPath(); - const { servers, warnings: proxyWarnings } = collectMcpServers(validPlugins); - if (proxyWarnings.length > 0) { - warnings.push(...proxyWarnings); - } - if (servers.size > 0) { - proxyBaseServers = servers; - } - } - - function getServersForClient(client: string): Map | undefined { - if (!mcpProxyConfig || !proxyMetadataPath || !proxyBaseServers) return undefined; - return applyMcpProxy(proxyBaseServers, client, mcpProxyConfig, proxyMetadataPath); - } - - // Step 5e: Sync MCP server configs to project-scoped .vscode/mcp.json + // Step 5e–h: Sync MCP server configs across all project-scoped clients. + // Delegated to mcp-sync.ts so the same pipeline can be reused by the + // standalone `allagents mcp update` command. sw.start('mcp-sync'); - const mcpResults: Record = {}; - if (syncClients.includes('vscode')) { - const trackedMcpServers = getPreviouslySyncedMcpServers(previousState, 'vscode'); - const projectMcpPath = join(workspacePath, '.vscode', 'mcp.json'); - const vscodeMcpOverrides = getServersForClient('vscode'); - const vscodeMcp = syncVscodeMcpConfig(validPlugins, { - dryRun, - force: false, - configPath: projectMcpPath, - trackedServers: trackedMcpServers, - ...(vscodeMcpOverrides && { serverOverrides: vscodeMcpOverrides }), - }); - if (vscodeMcp.warnings.length > 0) { - warnings.push(...vscodeMcp.warnings); - } - mcpResults.vscode = vscodeMcp; - } - - // Step 5f: Sync MCP server configs to project-scoped .mcp.json (read by Claude Code) - if (syncClients.includes('claude')) { - const trackedMcpServers = getPreviouslySyncedMcpServers(previousState, 'claude'); - const projectMcpJsonPath = join(workspacePath, '.mcp.json'); - const claudeMcpOverrides = getServersForClient('claude'); - const claudeMcp = syncClaudeMcpConfig(validPlugins, { - dryRun, - force: false, - configPath: projectMcpJsonPath, - trackedServers: trackedMcpServers, - ...(claudeMcpOverrides && { serverOverrides: claudeMcpOverrides }), - }); - if (claudeMcp.warnings.length > 0) { - warnings.push(...claudeMcp.warnings); - } - mcpResults.claude = claudeMcp; - } - - // Step 5g: Sync MCP server configs to project-scoped .codex/config.toml - if (syncClients.includes('codex')) { - const trackedMcpServers = getPreviouslySyncedMcpServers(previousState, 'codex'); - const projectCodexConfigPath = join(workspacePath, '.codex', 'config.toml'); - const codexMcpOverrides = getServersForClient('codex'); - const codexMcp = syncCodexProjectMcpConfig(validPlugins, { - dryRun, - force: false, - configPath: projectCodexConfigPath, - trackedServers: trackedMcpServers, - ...(codexMcpOverrides && { serverOverrides: codexMcpOverrides }), - }); - if (codexMcp.warnings.length > 0) { - warnings.push(...codexMcp.warnings); - } - mcpResults.codex = codexMcp; - } - - // Step 5h: Sync MCP server configs to project-scoped .copilot/mcp-config.json - if (syncClients.includes('copilot')) { - const trackedMcpServers = getPreviouslySyncedMcpServers(previousState, 'copilot'); - const projectCopilotMcpPath = join(workspacePath, '.copilot', 'mcp-config.json'); - const copilotMcpOverrides = getServersForClient('copilot'); - const copilotMcp = syncClaudeMcpConfig(validPlugins, { - dryRun, - force: false, - configPath: projectCopilotMcpPath, - trackedServers: trackedMcpServers, - ...(copilotMcpOverrides && { serverOverrides: copilotMcpOverrides }), - }); - if (copilotMcp.warnings.length > 0) { - warnings.push(...copilotMcp.warnings); - } - mcpResults.copilot = copilotMcp; - } - + const mcpSyncResult = runMcpSync( + workspacePath, + validPlugins, + config, + previousState, + syncClients, + { dryRun }, + ); + const mcpResults: Record = { ...mcpSyncResult.mcpResults }; + warnings.push(...mcpSyncResult.warnings); sw.stop('mcp-sync'); - // Warn about clients that don't support project-scoped MCP sync - const PROJECT_MCP_CLIENTS = new Set(['claude', 'codex', 'vscode', 'copilot', 'universal']); - const allMcpServers = proxyBaseServers ?? collectMcpServers(validPlugins).servers; - if (allMcpServers.size > 0) { - for (const client of syncClients) { - if (!PROJECT_MCP_CLIENTS.has(client)) { - warnings.push(`MCP servers not synced for ${client} (not supported at project scope)`); - } - } - } - // Count results const { totalCopied, totalFailed, totalSkipped, totalGenerated } = countCopyResults(pluginResults, workspaceFileResults); const hasFailures = pluginResults.some((r) => !r.success) || totalFailed > 0; @@ -2165,7 +2072,7 @@ export async function syncWorkspace( * This prevents fetchPlugin from performing a redundant git pull for repos * that the marketplace has already cloned/pulled. */ -async function seedFetchCacheFromMarketplaces( +export async function seedFetchCacheFromMarketplaces( results: Array<{ source: string; success: boolean; name?: string }>, ): Promise { for (const result of results) { @@ -2308,25 +2215,31 @@ export async function syncUserWorkspace( // MCP Proxy: prepare transform if configured (user-scoped) const userMcpProxyConfig = config.mcpProxy; + const userWorkspaceMcpServers = config.mcpServers; let userProxyMetadataPath: string | undefined; - let userProxyBaseServers: Map | undefined; if (userMcpProxyConfig) { if (!dryRun) { ensureProxyMetadata(); } userProxyMetadataPath = getProxyMetadataPath(); - const { servers, warnings: proxyWarnings } = collectMcpServers(validPlugins); - if (proxyWarnings.length > 0) { - warnings.push(...proxyWarnings); - } - if (servers.size > 0) { - userProxyBaseServers = servers; - } } - function getUserServersForClient(client: string): Map | undefined { - if (!userMcpProxyConfig || !userProxyMetadataPath || !userProxyBaseServers) return undefined; - return applyMcpProxy(userProxyBaseServers, client, userMcpProxyConfig, userProxyMetadataPath); + // Emit collection warnings once across all user-scoped client syncs. + let userCollectWarningsEmitted = false; + function getUserServersForClient(client: ClientType): Map { + const { servers, warnings: collectWarnings } = collectMcpServers( + validPlugins, + userWorkspaceMcpServers, + client, + ); + if (!userCollectWarningsEmitted) { + warnings.push(...collectWarnings); + userCollectWarningsEmitted = true; + } + if (userMcpProxyConfig && userProxyMetadataPath) { + return applyMcpProxy(servers, client, userMcpProxyConfig, userProxyMetadataPath); + } + return servers; } // Sync MCP server configs to VS Code if vscode client is configured @@ -2339,7 +2252,7 @@ export async function syncUserWorkspace( dryRun, force, trackedServers: trackedMcpServers, - ...(vscodeMcpOverrides && { serverOverrides: vscodeMcpOverrides }), + serverOverrides: vscodeMcpOverrides, }); if (vscodeMcp.warnings.length > 0) { warnings.push(...vscodeMcp.warnings); @@ -2399,7 +2312,7 @@ export async function syncUserWorkspace( // Warn about clients that don't support user-scoped MCP sync const USER_MCP_CLIENTS = new Set(['claude', 'codex', 'vscode', 'copilot', 'universal']); - const allUserMcpServers = userProxyBaseServers ?? collectMcpServers(validPlugins).servers; + const allUserMcpServers = collectMcpServers(validPlugins, userWorkspaceMcpServers).servers; if (allUserMcpServers.size > 0) { for (const client of syncClients) { if (!USER_MCP_CLIENTS.has(client)) { diff --git a/src/core/vscode-mcp.ts b/src/core/vscode-mcp.ts index a576232..7460d4c 100644 --- a/src/core/vscode-mcp.ts +++ b/src/core/vscode-mcp.ts @@ -3,6 +3,8 @@ import { join, dirname } from 'node:path'; import JSON5 from 'json5'; import { getHomeDir } from '../constants.js'; import type { ValidatedPlugin } from './sync.js'; +import type { ClientType } from '../models/workspace-config.js'; +import type { McpServerConfig } from '../models/workspace-config.js'; /** * Deep equality check for MCP server configs. @@ -88,11 +90,31 @@ export function readPluginMcpConfig(pluginPath: string): Record } /** - * Collect MCP servers from all validated plugins. - * First-plugin-wins for duplicate server names. + * Strip allagents-only metadata fields from a workspace MCP server config + * before it is passed to a client sync function. Currently removes `clients` + * (a sync filter that should never be written into client MCP configs). + */ +function stripWorkspaceMcpMeta(config: McpServerConfig): Record { + const { clients: _clients, ...rest } = config as McpServerConfig & { + clients?: ClientType[]; + }; + return rest as Record; +} + +/** + * Collect MCP servers from all validated plugins, optionally merged with + * workspace-level inline servers. + * + * Precedence: workspace > plugin. Name conflicts emit a warning. + * If a targetClient is supplied, workspace servers whose `clients` list + * excludes it are omitted. + * + * Plugin-level conflicts fall back to first-plugin-wins. */ export function collectMcpServers( validatedPlugins: ValidatedPlugin[], + workspaceServers?: Record, + targetClient?: ClientType, ): { servers: Map; warnings: string[] } { const servers = new Map(); const warnings: string[] = []; @@ -110,6 +132,20 @@ export function collectMcpServers( } } + if (workspaceServers) { + for (const [name, config] of Object.entries(workspaceServers)) { + if (targetClient && config.clients && !config.clients.includes(targetClient)) { + continue; + } + if (servers.has(name)) { + warnings.push( + `MCP server '${name}' from workspace.yaml overrides plugin-provided server`, + ); + } + servers.set(name, stripWorkspaceMcpMeta(config)); + } + } + return { servers, warnings }; } diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 51c3188..66fa5a5 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -301,6 +301,39 @@ export const McpProxyConfigSchema = z.object({ export type McpProxyConfig = z.infer; +/** + * Workspace-level MCP server definition (top-level `mcpServers:` field in + * workspace.yaml). Allows declaring ad-hoc MCP servers without authoring a + * plugin. Supports both HTTP (url) and stdio (command) transports. + * + * Optional `clients:` filter restricts which client scopes receive the server. + * When absent, the server is synced to every configured client that supports + * project-scoped MCP (claude, codex, vscode, copilot). + */ +export const McpServerConfigSchema = z.union([ + // HTTP transport + z + .object({ + type: z.enum(['http']).optional(), + url: z.string(), + headers: z.record(z.string()).optional(), + clients: z.array(ClientTypeSchema).optional(), + }) + .strict(), + // stdio transport + z + .object({ + type: z.enum(['stdio']).optional(), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + clients: z.array(ClientTypeSchema).optional(), + }) + .strict(), +]); + +export type McpServerConfig = z.infer; + /** * Complete workspace configuration (workspace.yaml) */ @@ -313,6 +346,12 @@ export const WorkspaceConfigSchema = z.object({ vscode: VscodeConfigSchema.optional(), syncMode: SyncModeSchema.optional(), mcpProxy: McpProxyConfigSchema.optional(), + /** + * Inline MCP server definitions. Merged with plugin-provided .mcp.json + * servers during sync. Workspace-defined servers take precedence over + * plugin-defined servers on name conflicts. + */ + mcpServers: z.record(McpServerConfigSchema).optional(), /** @deprecated Use inline skills field on plugin entry instead. Will be removed in v3. */ disabledSkills: z.array(z.string()).optional(), /** @deprecated Use inline skills field on plugin entry instead. Will be removed in v3. */ diff --git a/tests/unit/core/mcp-servers.test.ts b/tests/unit/core/mcp-servers.test.ts new file mode 100644 index 0000000..c79988c --- /dev/null +++ b/tests/unit/core/mcp-servers.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { load } from 'js-yaml'; +import { + addWorkspaceMcpServer, + buildMcpServerConfigFromFlags, + getWorkspaceMcpServer, + listWorkspaceMcpServers, + parseKeyValuePairs, + removeWorkspaceMcpServer, +} from '../../../src/core/mcp-servers.js'; + +function makeTempWorkspace(): string { + const dir = join( + tmpdir(), + `mcp-servers-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(join(dir, '.allagents'), { recursive: true }); + writeFileSync( + join(dir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - claude\n', + 'utf-8', + ); + return dir; +} + +function readWorkspace(dir: string): Record { + return load(readFileSync(join(dir, '.allagents', 'workspace.yaml'), 'utf-8')) as Record< + string, + unknown + >; +} + +describe('addWorkspaceMcpServer', () => { + let dir: string; + beforeEach(() => { + dir = makeTempWorkspace(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('adds an http server', async () => { + const result = await addWorkspaceMcpServer( + 'deepwiki', + { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, + dir, + ); + expect(result.success).toBe(true); + + const cfg = readWorkspace(dir); + expect(cfg.mcpServers).toEqual({ + deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, + }); + }); + + test('rejects duplicate without force', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir); + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + + test('replaces duplicate with force', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir, true); + expect(result.success).toBe(true); + const cfg = readWorkspace(dir); + expect((cfg.mcpServers as Record).a.command).toBe('y'); + }); + + test('rejects invalid config', async () => { + // Neither command nor url + const result = await addWorkspaceMcpServer( + 'bad', + { type: 'http' } as unknown as Parameters[1], + dir, + ); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid MCP server config'); + }); + + test('preserves other workspace.yaml fields on add', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + const cfg = readWorkspace(dir); + expect(cfg.repositories).toEqual([]); + expect(cfg.plugins).toEqual([]); + expect(cfg.clients).toEqual(['claude']); + }); +}); + +describe('removeWorkspaceMcpServer', () => { + let dir: string; + beforeEach(() => { + dir = makeTempWorkspace(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('removes an existing server', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + const result = await removeWorkspaceMcpServer('a', dir); + expect(result.success).toBe(true); + const cfg = readWorkspace(dir); + expect(cfg.mcpServers).toBeUndefined(); + }); + + test('fails when server does not exist', async () => { + const result = await removeWorkspaceMcpServer('nonexistent', dir); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); +}); + +describe('getWorkspaceMcpServer / listWorkspaceMcpServers', () => { + let dir: string; + beforeEach(() => { + dir = makeTempWorkspace(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('get returns null for missing server', async () => { + const cfg = await getWorkspaceMcpServer('missing', dir); + expect(cfg).toBeNull(); + }); + + test('get returns server config', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + const cfg = await getWorkspaceMcpServer('a', dir); + expect(cfg).toEqual({ command: 'x' } as unknown as typeof cfg); + }); + + test('list returns all servers', async () => { + await addWorkspaceMcpServer('a', { command: 'x' }, dir); + await addWorkspaceMcpServer( + 'b', + { type: 'http', url: 'https://b.test' }, + dir, + ); + const servers = await listWorkspaceMcpServers(dir); + expect(Object.keys(servers).sort()).toEqual(['a', 'b']); + }); + + test('list returns empty object when none defined', async () => { + const servers = await listWorkspaceMcpServers(dir); + expect(servers).toEqual({}); + }); +}); + +describe('buildMcpServerConfigFromFlags', () => { + test('auto-detects http transport from URL', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'https://mcp.example.com', + }); + expect('config' in result).toBe(true); + if ('config' in result) { + expect(result.config).toEqual({ type: 'http', url: 'https://mcp.example.com' }); + } + }); + + test('builds stdio config with args and env', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'npx', + args: ['-y', 'mcp-server'], + env: { KEY: 'value' }, + }); + expect('config' in result).toBe(true); + if ('config' in result) { + expect(result.config).toEqual({ + type: 'stdio', + command: 'npx', + args: ['-y', 'mcp-server'], + env: { KEY: 'value' }, + }); + } + }); + + test('rejects args for http transport', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'https://mcp.example.com', + args: ['foo'], + }); + expect('error' in result).toBe(true); + }); + + test('rejects explicit http transport for non-URL', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'npx', + transport: 'http', + }); + expect('error' in result).toBe(true); + }); + + test('rejects explicit stdio transport for URL', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'https://mcp.example.com', + transport: 'stdio', + }); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error).toContain('stdio transport requires a command'); + } + }); + + test('applies client filter', () => { + const result = buildMcpServerConfigFromFlags({ + commandOrUrl: 'npx', + clients: ['claude'], + }); + expect('config' in result).toBe(true); + if ('config' in result) { + expect(result.config.clients).toEqual(['claude']); + } + }); +}); + +describe('parseKeyValuePairs', () => { + test('parses KEY=VALUE pairs', () => { + const result = parseKeyValuePairs(['A=1', 'B=two'], '-e'); + expect('values' in result).toBe(true); + if ('values' in result) { + expect(result.values).toEqual({ A: '1', B: 'two' }); + } + }); + + test('rejects pair missing =', () => { + const result = parseKeyValuePairs(['bad'], '-e'); + expect('error' in result).toBe(true); + }); + + test('preserves = in value', () => { + const result = parseKeyValuePairs(['URL=http://x?a=1'], '-e'); + if ('values' in result) { + expect(result.values.URL).toBe('http://x?a=1'); + } + }); +});