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..f71b61d0e2 100644 --- a/docs/src/content/docs/reference/scripts/mcp-tools.mdx +++ b/docs/src/content/docs/reference/scripts/mcp-tools.mdx @@ -46,6 +46,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/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..06a3a5149f --- /dev/null +++ b/packages/cli/src/mcp-config.ts @@ -0,0 +1,126 @@ +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 + 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/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/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", } 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 {