-
Notifications
You must be signed in to change notification settings - Fork 5k
feat(cli): auto-discover MCP servers in .continue/mcpServers #12922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+162
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The new MCP JSON discovery hook is not guarded inside ConfigService, so an unexpected exception from Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private processAgentFileRules( | ||||||||||||||||||||||||||||||
| agentFileState: AgentFileServiceState | undefined, | ||||||||||||||||||||||||||||||
| packageIdentifiers: PackageIdentifier[], | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Weak assertion in Claude-desktop test: only checks server name, does not verify command/args properties are correctly parsed through convertJsonMcpConfigToYamlMcpConfig Prompt for AI agents |
||
| }); | ||
|
|
||
| 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" }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof record> => !!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(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Workspace MCP server discovery defaults to Prompt for AI agents |
||
| ): MCPServerConfig[] { | ||
| const servers: MCPServerConfig[] = []; | ||
| const seenNames = new Set<string>(); | ||
|
|
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Auto-discovered MCP servers should be filtered against servers already present in
additional.mcpServersbefore pushing, and the precedence relative to the loaded assistant config should be explicit. Currently the JSON discovery path appends blindly, relying on downstream merge order and risking silent override of config-defined servers.Prompt for AI agents