Skip to main content
Glama
main.js12.7 kB
#!/usr/bin/env node /** * @file This script implements a Dynamic MCP (Model Context Protocol) Server. * It loads tool definitions from a JSON configuration file, allowing for the * creation of multiple MCP server instances from a single codebase. * The server can interact with different AI models via their command-line interfaces. */ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); const { z } = require("zod"); const execa = require("execa"); const { readFileSync, existsSync } = require("node:fs"); const { resolve, basename } = require("node:path"); const { version: packageVersion } = require("../package.json"); /** * Configuration for supported AI model CLIs. * Defines the command and base arguments for each model. */ const CLI_CONFIG = { claude: { command: "claude", baseArgs: ["--dangerously-skip-permissions", "-p"], }, codex: { command: "codex", baseArgs: ["--dangerously-bypass-approvals-and-sandbox", "--search", "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check"], }, gemini: { command: "gemini", baseArgs: ["-y", "-p"], } }; /** * Zod schemas for validating the JSON configuration file. */ const ConfigSchemas = { ConfigInput: z.object({ name: z.string(), type: z.enum(["string", "number", "boolean", "array", "object"]), description: z.string(), required: z.boolean().optional().default(true), }), ConfigTool: z.object({ name: z.string(), description: z.string(), command: z.string().optional(), args: z.array(z.any()).optional(), prompt: z.string().optional(), promptFile: z.string().optional(), inputs: z.array(z.lazy(() => ConfigSchemas.ConfigInput)).optional().default([]), }), Config: z.object({ name: z.string().optional(), model: z.enum(["claude", "codex", "gemini"]), modelId: z.string().optional(), tools: z.array(z.lazy(() => ConfigSchemas.ConfigTool)).min(1), }), }; /** * A in-memory store for tracking asynchronous jobs. */ const jobs = new Map(); let jobIdCounter = 0; /** * Generates a unique ID for a new job. * @returns {string} A unique job ID. */ function generateJobId() { return `job_${Date.now()}_${++jobIdCounter}`; } function parseCliArgs() { let configPath = null; let promptArg = null; let asyncArg = false; let handshakeAndExitArg = false; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg === "--config") { configPath = process.argv[++i]; if (!configPath) { console.error("Error: --config requires a value (file path)"); process.exit(1); } } else if (arg === "--prompt" || arg === "-p") { promptArg = process.argv[++i]; if (!promptArg) { console.error("Error: --prompt requires a value (string or file path)"); process.exit(1); } } else if (arg === "--async") { asyncArg = true; } else if (arg === "--handshake-and-exit") { handshakeAndExitArg = true; } } if (!configPath) { console.error("Error: a path to a config file must be provided with --config."); process.exit(1); } return { configPath, promptArg, asyncArg, handshakeAndExitArg }; } /** * Converts a JSON type string to a Zod schema object. * @param {string} type - The JSON type. * @param {string} description - The description for the schema. * @param {boolean} required - Whether the field is required. * @returns {z.ZodTypeAny} A Zod schema. */ function typeToZod(type, description, required) { let schema; switch (type) { case "string": schema = z.string(); break; case "number": schema = z.number(); break; case "boolean": schema = z.boolean(); break; case "array": schema = z.array(z.any()); break; case "object": schema = z.record(z.any()); break; default: schema = z.string(); } if (description) schema = schema.describe(description); if (!required) schema = schema.optional(); return schema; } /** * Builds a Zod input schema from a tool's input definitions. * @param {Array} inputs - The array of input definitions from the config. * @returns {Object} A Zod schema object. */ function buildInputSchema(inputs) { const schema = {}; for (const input of inputs) { schema[input.name] = typeToZod(input.type, input.description, input.required); } return schema; } /** * Loads the prompt for a tool, preferring a prompt file over an inline prompt. * @param {z.infer<typeof ConfigSchemas.ConfigTool>} tool - The tool definition. * @returns {string|null} The prompt template. */ function loadToolPrompt(tool) { if (tool.promptFile) { const resolvedPath = resolve(tool.promptFile); if (existsSync(resolvedPath)) { try { return readFileSync(resolvedPath, "utf-8"); } catch (error) { console.error(`Error reading promptFile for tool '${tool.name}': ${error.message}`); process.exit(1); } } } return tool.prompt || null; } /** * Substitutes variables in a prompt template. * @param {string} template - The prompt template with {{variable}} placeholders. * @param {Object} params - The parameters to substitute. * @returns {string} The prompt with substituted variables. */ function substitutePromptVariables(template, params) { const safeTemplate = template || ""; return safeTemplate.replace(/\{\{([^}]+)\}\}/g, (match, key) => { return Object.prototype.hasOwnProperty.call(params, key) ? String(params[key] ?? "") : ""; }); } /** * Executes a task by spawning a CLI process. * @param {string} model - The model to use ('claude' or 'codex'). * @param {string|undefined} modelId - The specific model ID. * @param {string} task - The task/prompt to execute. * @param {string|undefined} cwd - The working directory. * @returns {Promise<{exitCode: number, stdout: string, stderr: string}>} */ async function executeTask(model, modelId, task, cwd) { const cliConfig = CLI_CONFIG[model]; const args = [...cliConfig.baseArgs, task]; if (modelId) args.unshift("--model", modelId); try { const { exitCode, stdout, stderr } = await execa(cliConfig.command, args, { cwd: cwd || process.cwd(), env: process.env, reject: false, all: false, stdin: "ignore", }); return { exitCode: exitCode ?? -1, stdout: stdout ?? "", stderr: stderr ?? "" }; } catch (error) { return { exitCode: -1, stdout: "", stderr: error.message }; } } /** * Starts a task asynchronously. * @param {string} model - The model to use. * @param {string|undefined} modelId - The specific model ID. * @param {string} task - The task/prompt to execute. * @param {string|undefined} cwd - The working directory. * @param {string} toolName - The name of the tool being executed. * @returns {string} The job ID. */ function startTaskAsync(model, modelId, task, cwd, toolName) { const jobId = generateJobId(); jobs.set(jobId, { status: "running", toolName, startedAt: new Date().toISOString(), result: null }); const cliConfig = CLI_CONFIG[model]; const args = [...cliConfig.baseArgs, task]; if (modelId) args.unshift("--model", modelId); const subprocess = execa(cliConfig.command, args, { cwd: cwd || process.cwd(), env: process.env, reject: false, all: false, stdin: "ignore", }); subprocess.then(({ exitCode, stdout, stderr }) => { jobs.set(jobId, { ...jobs.get(jobId), status: (exitCode ?? -1) === 0 ? "completed" : "failed", completedAt: new Date().toISOString(), result: { exitCode: exitCode ?? -1, stdout: stdout ?? "", stderr: stderr ?? "" }, }); }).catch((error) => { jobs.set(jobId, { ...jobs.get(jobId), status: "failed", completedAt: new Date().toISOString(), result: { exitCode: -1, stdout: "", stderr: error.message }, }); }); return jobId; } /** * The main function to set up and start the MCP server. */ function loadConfig(configPath) { const resolvedConfigPath = resolve(configPath); if (!existsSync(resolvedConfigPath)) { console.error(`Error: Configuration file not found: ${resolvedConfigPath}`); process.exit(1); } try { const configContent = readFileSync(resolvedConfigPath, "utf-8"); const config = JSON.parse(configContent); return ConfigSchemas.Config.parse(config); } catch (error) { console.error(`Error processing configuration file: ${error.message}`); process.exit(1); } } function loadPromptPrefix(promptArg) { if (!promptArg) return null; const resolvedPromptPath = resolve(promptArg); if (existsSync(resolvedPromptPath)) { try { return readFileSync(resolvedPromptPath, "utf-8"); } catch (error) { console.error(`Error reading prompt file: ${error.message}`); process.exit(1); } } return promptArg; } /** * Build the handshake payload for --handshake-and-exit mode. * Kept minimal to satisfy integration tests and external consumers. */ function createHandshakeSummary(config, serverName) { return { mcp_version: "1.0", server_name: serverName, server_version: packageVersion ?? "0.0.0", tools: config.tools.map(tool => ({ toolName: tool.name, command: tool.command || null, args: tool.args || null, })), }; } /** * The main function to set up and start the MCP server. */ async function main() { const { configPath, promptArg, asyncArg, handshakeAndExitArg } = parseCliArgs(); const validatedConfig = loadConfig(configPath); const promptPrefix = loadPromptPrefix(promptArg); const serverName = validatedConfig.name || basename(configPath, ".json") + "-mcp-server"; const server = new McpServer({ name: serverName, version: packageVersion ?? "0.0.0", }); const pollForJobCompletion = (jobId, resolve) => { const job = jobs.get(jobId); if (job.status === "completed" || job.status === "failed") { resolve({ content: [{ type: "text", text: `stdout: ${job.result.stdout}\nstderr: ${job.result.stderr}` }], structuredContent: job.result, isError: job.status === "failed", }); } else { setTimeout(() => pollForJobCompletion(jobId, resolve), 1000); } }; validatedConfig.tools.forEach(tool => { const inputSchema = buildInputSchema(tool.inputs); const toolPromptTemplate = loadToolPrompt(tool); if (asyncArg) { server.registerTool(tool.name, { description: tool.description, inputSchema, outputSchema: { exitCode: z.number(), stdout: z.string(), stderr: z.string() } }, (params) => { return new Promise((resolve) => { const { cwd, ...toolParams } = params; const task = substitutePromptVariables(toolPromptTemplate, toolParams); const fullTask = promptPrefix ? `${promptPrefix}\n${task}` : task; const jobId = startTaskAsync(validatedConfig.model, validatedConfig.modelId, fullTask, cwd, tool.name); pollForJobCompletion(jobId, resolve); }); }); } else { server.registerTool(tool.name, { description: tool.description, inputSchema, outputSchema: { exitCode: z.number(), stdout: z.string(), stderr: z.string() } }, async (params) => { const { cwd, ...toolParams } = params; const task = substitutePromptVariables(toolPromptTemplate, toolParams); const fullTask = promptPrefix ? `${promptPrefix}\n${task}` : task; const result = await executeTask(validatedConfig.model, validatedConfig.modelId, fullTask, cwd); return { content: [{ type: "text", text: `stdout: ${result.stdout}\nstderr: ${result.stderr}` }], structuredContent: result, isError: result.exitCode !== 0, }; }); } }); const transport = new StdioServerTransport(); await server.connect(transport); if (handshakeAndExitArg) { const handshake = createHandshakeSummary(validatedConfig, serverName); // Print handshake to stdout so tests/clients can parse it. console.log(JSON.stringify(handshake)); console.error('Exiting after handshake'); setTimeout(() => process.exit(0), 50); } } if (require.main === module) { main().catch(error => { console.error(`Unhandled error: ${error.message}`); process.exit(1); }); } if (process.env.NODE_ENV === 'test') { module.exports = { parseCliArgs, loadConfig, loadPromptPrefix, substitutePromptVariables, executeTask, startTaskAsync, createHandshakeSummary }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/PortlandKyGuy/dynamic-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server