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..ba598f5d29 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) { + 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); + } + } + 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..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,6 +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") .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.ts b/packages/core/src/mcp-config.ts new file mode 100644 index 0000000000..86a7bd66dc --- /dev/null +++ b/packages/core/src/mcp-config.ts @@ -0,0 +1,128 @@ +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; +} 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 { diff --git a/packages/core/test/mcp-config.test.ts b/packages/core/test/mcp-config.test.ts new file mode 100644 index 0000000000..9c344aa2f0 --- /dev/null +++ b/packages/core/test/mcp-config.test.ts @@ -0,0 +1,143 @@ +// 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"; + +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); + }); +});