From 58c3659578fbd9f3fb332a14dd462ae0412880fb Mon Sep 17 00:00:00 2001 From: Kamil Chmielewski Date: Thu, 11 Sep 2025 02:00:01 +0200 Subject: [PATCH 1/4] fix litellm api key (#1921) --- packages/core/src/env.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index fea019428c..a20f9eba4e 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -776,11 +776,12 @@ export async function parseTokenFromEnv( if (!URL.canParse(base)) { throw new Error(`${base} must be a valid URL`) } + const token = env.LITELLM_API_KEY; return { provider, model, base, - token: MODEL_PROVIDER_LITELLM, + token, type: "openai", source: "default", } From a02d87f7068cbb0c77d7ebff1e81f1c44f1337a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:47:06 +0000 Subject: [PATCH 2/4] Initial plan From d364e04bab24513ad0d4e9a083e79800990d58d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:59:01 +0000 Subject: [PATCH 3/4] Add MCP configuration file support to CLI run command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../content/docs/reference/cli/commands.md | 1 + .../docs/reference/scripts/mcp-tools.mdx | 58 +++++++ packages/cli/src/cli.ts | 4 + packages/cli/src/mcp-config.test.ts | 142 ++++++++++++++++++ packages/cli/src/mcp-config.ts | 113 ++++++++++++++ packages/cli/src/run.ts | 19 +++ packages/core/src/server/messages.ts | 1 + 7 files changed, 338 insertions(+) create mode 100644 packages/cli/src/mcp-config.test.ts create mode 100644 packages/cli/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 6edd16efe7..03bc7d7d5d 100644 --- a/docs/src/content/docs/reference/cli/commands.md +++ b/docs/src/content/docs/reference/cli/commands.md @@ -83,6 +83,7 @@ Options: -rr, --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 8eabfed98d..c01506d78f 100644 --- a/docs/src/content/docs/reference/scripts/mcp-tools.mdx +++ b/docs/src/content/docs/reference/scripts/mcp-tools.mdx @@ -46,6 +46,64 @@ 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: + +```json title="mcp.json" +{ + "servers": { + "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` + +```json title="Example with environment variables" +{ + "servers": { + "custom-server": { + "command": "${env:MCP_SERVER_PATH}", + "args": ["--port", "${env:MCP_PORT}"], + "cwd": "${workspaceFolder}/servers", + "env": { + "DEBUG": "${env:DEBUG}", + "API_KEY": "${env: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/cli/src/cli.ts b/packages/cli/src/cli.ts index e7bae2e7bd..aa7670464b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -259,6 +259,10 @@ export async function cli() { ) .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/cli/src/mcp-config.test.ts b/packages/cli/src/mcp-config.test.ts new file mode 100644 index 0000000000..c7d8ecb676 --- /dev/null +++ b/packages/cli/src/mcp-config.test.ts @@ -0,0 +1,142 @@ +import { loadClaudeMcpConfig } from "../src/mcp-config" +import { writeJSON, readJSON } from "fs-extra" +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/cli/src/mcp-config.ts b/packages/cli/src/mcp-config.ts new file mode 100644 index 0000000000..2a6eb6bbb4 --- /dev/null +++ b/packages/cli/src/mcp-config.ts @@ -0,0 +1,113 @@ +import { readJSON } from "fs-extra" +import { resolve, dirname } from "node:path" +import { existsSync } from "node:fs" + +/** + * Claude MCP configuration file format + */ +interface ClaudeMcpConfig { + servers: 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}, 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(/\$\{([^}]+)\}/g, (_, varName) => { + // Handle other variable types if needed + if (varName === "workspaceFolder") return workspaceFolder + return 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) + + if (!existsSync(resolvedPath)) { + throw new Error(`MCP configuration file not found: ${resolvedPath}`) + } + + let config: ClaudeMcpConfig + try { + config = await readJSON(resolvedPath) + } catch (error) { + throw new Error(`Failed to parse MCP configuration file: ${error.message}`) + } + + if (!config.servers || typeof config.servers !== "object") { + throw new Error("Invalid MCP configuration: missing or invalid 'servers' object") + } + + // Use config file directory as workspace folder if not provided + const wsFolder = workspaceFolder || dirname(resolvedPath) + + // Convert Claude format to GenAIScript format + const mcpServers: Record = {} + + for (const [serverId, serverConfig] of Object.entries(config.servers)) { + // Interpolate variables in the server configuration + const interpolatedConfig = interpolateObjectValues(serverConfig, wsFolder) + + // Convert to GenAIScript McpServerConfig format + const genaiscriptConfig = { + command: interpolatedConfig.command, + args: interpolatedConfig.args || [], + env: interpolatedConfig.env, + cwd: interpolatedConfig.cwd + } + + mcpServers[serverId] = genaiscriptConfig + } + + return mcpServers +} \ No newline at end of file diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 53bacac4f3..57254dd376 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -113,6 +113,7 @@ import { genaiscriptDebug } from "../../core/src/debug" import { uriTryParse } from "../../core/src/url" import { tryResolveScript } from "../../core/src/scriptresolver" import { isCI } from "../../core/src/ci" +import { loadClaudeMcpConfig } from "./mcp-config" const dbg = genaiscriptDebug("run") /** @@ -569,6 +570,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/core/src/server/messages.ts b/packages/core/src/server/messages.ts index 949034d91f..649034d3ba 100644 --- a/packages/core/src/server/messages.ts +++ b/packages/core/src/server/messages.ts @@ -183,6 +183,7 @@ export interface PromptScriptRunOptions { runTrace: boolean outputTrace: boolean accept: string + mcpConfig?: string } export interface RunResultList extends RequestMessage { From 064492a956b3edd8de60d351a94223ef69b1099f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:42:18 +0000 Subject: [PATCH 4/4] Add support for mcpServers key, capitalized env vars, and debug logging in MCP config Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/scripts/mcp-tools.mdx | 9 ++--- packages/cli/src/mcp-config.ts | 33 +++++++++++++------ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/src/content/docs/reference/scripts/mcp-tools.mdx b/docs/src/content/docs/reference/scripts/mcp-tools.mdx index c01506d78f..f71b61d0e2 100644 --- a/docs/src/content/docs/reference/scripts/mcp-tools.mdx +++ b/docs/src/content/docs/reference/scripts/mcp-tools.mdx @@ -56,11 +56,11 @@ You can also load MCP servers from a Claude format configuration file using the genaiscript run my-script --mcp-config .vscode/mcp.json ``` -The configuration file uses the Claude MCP format: +The configuration file uses the Claude MCP format and supports both `servers` and `mcpServers` as the top-level key: ```json title="mcp.json" { - "servers": { + "mcpServers": { "filesystem": { "type": "stdio", "command": "npx", @@ -83,17 +83,18 @@ 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", "${env:MCP_PORT}"], + "args": ["--port", "${MCP_PORT}"], "cwd": "${workspaceFolder}/servers", "env": { "DEBUG": "${env:DEBUG}", - "API_KEY": "${env:API_KEY}" + "API_KEY": "${API_KEY}" } } } diff --git a/packages/cli/src/mcp-config.ts b/packages/cli/src/mcp-config.ts index 2a6eb6bbb4..06a3a5149f 100644 --- a/packages/cli/src/mcp-config.ts +++ b/packages/cli/src/mcp-config.ts @@ -1,12 +1,16 @@ import { readJSON } from "fs-extra" import { resolve, dirname } from "node:path" import { existsSync } from "node:fs" +import { genaiscriptDebug } from "../../core/src/debug" + +const dbg = genaiscriptDebug("mcp:config") /** * Claude MCP configuration file format */ interface ClaudeMcpConfig { - servers: Record + servers?: Record + mcpServers?: Record } interface ClaudeMcpServerConfig { @@ -20,7 +24,7 @@ interface ClaudeMcpServerConfig { /** * Interpolates Claude environment variables in a string - * Supports ${workspaceFolder}, ${env:VARIABLE_NAME}, etc. + * Supports ${workspaceFolder}, ${env:VARIABLE_NAME}, ${VARIABLE_NAME} (for capitalized env vars), etc. */ function interpolateClaudeVariables( value: string, @@ -30,11 +34,7 @@ function interpolateClaudeVariables( return value .replace(/\$\{workspaceFolder\}/g, workspaceFolder) .replace(/\$\{env:([^}]+)\}/g, (_, varName) => env[varName] || "") - .replace(/\$\{([^}]+)\}/g, (_, varName) => { - // Handle other variable types if needed - if (varName === "workspaceFolder") return workspaceFolder - return env[varName] || "" - }) + .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, varName) => env[varName] || "") } /** @@ -73,6 +73,8 @@ export async function loadClaudeMcpConfig( ): Promise> { const resolvedPath = resolve(configPath) + dbg(`Loading MCP configuration from: ${resolvedPath}`) + if (!existsSync(resolvedPath)) { throw new Error(`MCP configuration file not found: ${resolvedPath}`) } @@ -80,24 +82,33 @@ export async function loadClaudeMcpConfig( 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}`) } - if (!config.servers || typeof config.servers !== "object") { - throw new Error("Invalid MCP configuration: missing or invalid 'servers' object") + // 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(config.servers)) { + 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, @@ -109,5 +120,7 @@ export async function loadClaudeMcpConfig( mcpServers[serverId] = genaiscriptConfig } + dbg(`Loaded ${Object.keys(mcpServers).length} MCP servers:`, Object.keys(mcpServers)) + return mcpServers } \ No newline at end of file