diff --git a/extensions/cli/src/services/ConfigService.ts b/extensions/cli/src/services/ConfigService.ts index 895b7b583bd..1be2e564acf 100644 --- a/extensions/cli/src/services/ConfigService.ts +++ b/extensions/cli/src/services/ConfigService.ts @@ -19,6 +19,7 @@ import { import { logger } from "../util/logger.js"; import { BaseService, ServiceWithDependencies } from "./BaseService.js"; +import { loadJsonMcpServers } from "./loadJsonMcpServers.js"; import { serviceContainer } from "./ServiceContainer.js"; import { ToolPermissionServiceState } from "./ToolPermissionService.js"; import { @@ -96,6 +97,7 @@ export class ConfigService packageIdentifiers, additional, ); + this.processJsonMcpServers(additional); this.processAgentFileRules(agentFileState, packageIdentifiers); this.processRulesAndPrompts( options.rule || [], @@ -157,6 +159,12 @@ export class ConfigService } } + private processJsonMcpServers(additional: AssistantUnrolled): void { + for (const server of loadJsonMcpServers()) { + additional.mcpServers!.push(server); + } + } + private processAgentFileRules( agentFileState: AgentFileServiceState | undefined, packageIdentifiers: PackageIdentifier[], diff --git a/extensions/cli/src/services/loadJsonMcpServers.test.ts b/extensions/cli/src/services/loadJsonMcpServers.test.ts new file mode 100644 index 00000000000..73b99a3fac4 --- /dev/null +++ b/extensions/cli/src/services/loadJsonMcpServers.test.ts @@ -0,0 +1,91 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { loadJsonMcpServers } from "./loadJsonMcpServers.js"; + +describe("loadJsonMcpServers", () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "cn-mcp-json-")); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + function writeWorkspaceMcpFile(fileName: string, content: unknown): void { + const dir = path.join(cwd, ".continue", "mcpServers"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, fileName), JSON.stringify(content)); + } + + it("returns an empty array when there is no mcpServers directory", () => { + expect(loadJsonMcpServers(cwd)).toEqual([]); + }); + + it("discovers a single-server stdio file, named after the file", () => { + writeWorkspaceMcpFile("playwright.json", { + command: "npx", + args: ["-y", "@playwright/mcp"], + }); + + const servers = loadJsonMcpServers(cwd); + + expect(servers).toHaveLength(1); + expect(servers[0]).toMatchObject({ + name: "playwright", + type: "stdio", + command: "npx", + args: ["-y", "@playwright/mcp"], + }); + }); + + it("discovers servers from a Claude-desktop-style mcpServers map", () => { + writeWorkspaceMcpFile("config.json", { + mcpServers: { weather: { command: "uvx", args: ["weather-mcp"] } }, + }); + + const servers = loadJsonMcpServers(cwd); + + expect(servers.map((s) => s.name)).toEqual(["weather"]); + }); + + it("discovers an http server and maps the transport type", () => { + writeWorkspaceMcpFile("remote.json", { + url: "https://example.com/mcp", + type: "http", + }); + + const servers = loadJsonMcpServers(cwd); + + expect(servers[0]).toMatchObject({ + name: "remote", + url: "https://example.com/mcp", + type: "streamable-http", + }); + }); + + it("skips files that do not match a supported MCP config format", () => { + writeWorkspaceMcpFile("notes.json", { hello: "world" }); + + expect(loadJsonMcpServers(cwd)).toEqual([]); + }); + + it("de-duplicates servers by name, keeping the first encountered", () => { + writeWorkspaceMcpFile("a.json", { + mcpServers: { dup: { command: "first" } }, + }); + writeWorkspaceMcpFile("b.json", { + mcpServers: { dup: { command: "second" } }, + }); + + const servers = loadJsonMcpServers(cwd); + + expect(servers).toHaveLength(1); + expect(servers[0]).toMatchObject({ name: "dup", command: "first" }); + }); +}); diff --git a/extensions/cli/src/services/loadJsonMcpServers.ts b/extensions/cli/src/services/loadJsonMcpServers.ts new file mode 100644 index 00000000000..ab2c6092ad3 --- /dev/null +++ b/extensions/cli/src/services/loadJsonMcpServers.ts @@ -0,0 +1,134 @@ +import * as fs from "fs"; +import * as path from "path"; + +import { + claudeCodeLikeConfigFileSchema, + claudeDesktopLikeConfigFileSchema, + convertJsonMcpConfigToYamlMcpConfig, + type McpJsonConfig, + mcpServersJsonSchema, +} from "@continuedev/config-yaml"; + +import { env } from "../env.js"; +import { getErrorString } from "../util/error.js"; +import { logger } from "../util/logger.js"; + +import { MCPServerConfig } from "./types.js"; + +const MCP_SERVERS_DIRNAME = "mcpServers"; + +interface NamedMcpJsonConfig { + name: string; + mcpJson: McpJsonConfig; +} + +// Workspace `.continue/mcpServers` and the global `~/.continue/mcpServers`, +// matching where the IDE extensions look for standalone MCP server files. +function getMcpServerDirs(cwd: string): string[] { + return [ + path.join(cwd, ".continue", MCP_SERVERS_DIRNAME), + path.join(env.continueHome, MCP_SERVERS_DIRNAME), + ]; +} + +// A file may hold a single server, a Claude-desktop-style `{ mcpServers: {...} }` +// map, or a Claude-code-style file with top-level and per-project servers. +function parseMcpServerFile( + content: string, + fileName: string, +): NamedMcpJsonConfig[] { + const json = JSON.parse(content); + + const claudeCode = claudeCodeLikeConfigFileSchema.safeParse(json); + if (claudeCode.success) { + const records = [ + claudeCode.data.mcpServers, + ...Object.values(claudeCode.data.projects).map((p) => p.mcpServers), + ]; + return records + .filter((record): record is NonNullable => !!record) + .flatMap((record) => + Object.entries(record).map(([name, mcpJson]) => ({ name, mcpJson })), + ); + } + + const claudeDesktop = claudeDesktopLikeConfigFileSchema.safeParse(json); + if (claudeDesktop.success) { + return Object.entries(claudeDesktop.data.mcpServers).map( + ([name, mcpJson]) => ({ name, mcpJson }), + ); + } + + const single = mcpServersJsonSchema.safeParse(json); + if (single.success) { + return [{ name: fileName.replace(/\.json$/, ""), mcpJson: single.data }]; + } + + throw new Error("file does not match a supported MCP JSON config format"); +} + +/** + * Discover MCP servers defined as standalone JSON files in `.continue/mcpServers` + * (workspace and global), bringing the CLI to parity with the IDE extensions. + * Returns configs in the same shape as the rest of the assistant's mcpServers. + */ +export function loadJsonMcpServers( + cwd: string = process.cwd(), +): MCPServerConfig[] { + const servers: MCPServerConfig[] = []; + const seenNames = new Set(); + + for (const dir of getMcpServerDirs(cwd)) { + let fileNames: string[]; + try { + if (!fs.existsSync(dir)) { + continue; + } + fileNames = fs + .readdirSync(dir) + .filter((fileName) => fileName.endsWith(".json")) + .sort(); + } catch (e) { + logger.warn( + `Failed to read MCP servers directory ${dir}: ${getErrorString(e)}`, + ); + continue; + } + + for (const fileName of fileNames) { + const filePath = path.join(dir, fileName); + let parsed: NamedMcpJsonConfig[]; + try { + parsed = parseMcpServerFile( + fs.readFileSync(filePath, "utf-8"), + fileName, + ); + } catch (e) { + logger.warn( + `Skipping invalid MCP server file ${filePath}: ${getErrorString(e)}`, + ); + continue; + } + + for (const { name, mcpJson } of parsed) { + if (seenNames.has(name)) { + continue; + } + try { + const { yamlConfig } = convertJsonMcpConfigToYamlMcpConfig( + name, + mcpJson, + ); + servers.push(yamlConfig as MCPServerConfig); + seenNames.add(name); + } catch (e) { + logger.warn( + `Failed to load MCP server "${name}" from ${filePath}: ${getErrorString(e)}`, + ); + } + } + } + } + + return servers; +}