Skip to main content
Glama
index.ts69 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { spawn, ChildProcess } from "child_process"; import { Command } from "commander"; import http from "http"; import { randomUUID } from "crypto"; import { readFile, mkdir } from "fs/promises"; import { ChildServerManager, type ServerConfig } from "./childServerManager.js"; import { AsyncLocalStorage } from "async_hooks"; // Request context storage for execution ID scoping interface RequestContext { executionId?: string; } const requestContext = new AsyncLocalStorage<RequestContext>(); // HTTP Timeout Configuration Constants // These defaults accommodate the maximum command execution time (10 minutes) // with a safe buffer to ensure HTTP connections don't timeout prematurely const DEFAULT_HTTP_TIMEOUT = 15 * 60 * 1000; // 15 minutes (900,000ms) const DEFAULT_SOCKET_TIMEOUT = 15 * 60 * 1000; // 15 minutes (900,000ms) const DEFAULT_KEEP_ALIVE_TIMEOUT = 5000; // 5 seconds (Node.js default) /** * Get the workspace path for the current request. * Returns the workspace path configured via CLI (default: /app/workspace) */ function getWorkspacePath(): string { return WORKSPACE; } /** * Ensure the workspace directory exists for the current request */ async function ensureWorkspaceExists(): Promise<void> { const workspacePath = getWorkspacePath(); try { await mkdir(workspacePath, { recursive: true }); } catch (error) { console.error(`Warning: Failed to create workspace directory ${workspacePath}:`, error); } } // Parse CLI arguments const program = new Command(); program .name('docker-mcp-server') .description('MCP server for Docker container execution') .version('1.0.0') .option('-p, --port <port>', 'Port to listen on', '30000') .option('-t, --token <token>', 'Bearer token for authentication (auto-generated if not provided)') .option('-w, --workspace <path>', 'Working directory for all operations', '/app/workspace') .option('--http-timeout <ms>', 'HTTP request timeout in milliseconds', String(DEFAULT_HTTP_TIMEOUT)) .option('--socket-timeout <ms>', 'Socket inactivity timeout in milliseconds', String(DEFAULT_SOCKET_TIMEOUT)) .parse(); const options = program.opts(); const PORT = parseInt(options.port, 10); const AUTH_TOKEN = options.token || randomUUID(); const WORKSPACE = options.workspace as string; const HTTP_TIMEOUT = parseInt(options.httpTimeout, 10); const SOCKET_TIMEOUT = parseInt(options.socketTimeout, 10); interface ProcessInfo { startTime: number; command: string; bashProcess?: ChildProcess; status: 'running' | 'completed'; endTime?: number; exitCode?: number; result?: string; currentStdout?: string; currentStderr?: string; lastOutputTime?: number; inactivityTimeout: number; } const processes = new Map<string, ProcessInfo>(); function generateProcessId(): string { return `proc_${Date.now()}_${Math.random().toString(36).substring(2)}`; } /** * Truncate output that exceeds maxLength characters. * Keeps the beginning (~80%) and end (~20%) of the output to preserve * important context like errors at the start and exit codes at the end. */ function truncateOutput(output: string, maxLength: number = 30000): string { if (output.length <= maxLength) { return output; } const truncatedChars = output.length - maxLength; const headLength = Math.floor(maxLength * 0.8); const tailLength = maxLength - headLength; const head = output.substring(0, headLength); const tail = output.substring(output.length - tailLength); return `${head}\n\n[... truncated ${truncatedChars} characters ...]\n\n${tail}`; } // Parse ALLOWED_TOOLS environment variable // If set, only these native tools will be registered // If not set, all native tools will be registered (default behavior) const ALLOWED_TOOLS_ENV = process.env.ALLOWED_TOOLS; const allowedTools: Set<string> | null = ALLOWED_TOOLS_ENV ? new Set(ALLOWED_TOOLS_ENV.split(',').map(t => t.trim()).filter(t => t.length > 0)) : null; /** * Check if a native tool should be registered based on ALLOWED_TOOLS environment variable. * Returns true if ALLOWED_TOOLS is not set (register all) or if the tool is in the allowed list. */ function shouldRegisterTool(toolName: string): boolean { if (allowedTools === null) { return true; // No restriction, register all tools } return allowedTools.has(toolName); } const server = new McpServer({ name: "docker-mcp-server", version: "1.0.0" }); // Child server manager for aggregating multiple MCP servers const childServerManager = new ChildServerManager(); if (shouldRegisterTool("execute_command")) { server.registerTool( "execute_command", { title: "Execute Command", description: `Execute a shell command. Working directory: ${WORKSPACE}`, inputSchema: { command: z.string().describe("The shell command to execute"), inactivityTimeout: z.number().optional().describe("Seconds of inactivity before backgrounding (default: 20, max: 600)") } }, async ({ command, inactivityTimeout = 20 }) => { // Validate and normalize inactivityTimeout // Cap at 600 seconds (10 minutes, which is our hard maximum) if (inactivityTimeout > 600) { inactivityTimeout = 600; } // Ensure workspace directory exists before executing command await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Executing command in ${workspacePath}: ${command}`); return new Promise((resolve) => { const processId = generateProcessId(); const bashProcess = spawn("bash", [], { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); if (!bashProcess.stdin || !bashProcess.stdout || !bashProcess.stderr) { resolve({ content: [{ type: "text" as const, text: "Error: Failed to create process streams\nExit code: 1" }] }); return; } // Track this process processes.set(processId, { startTime: Date.now(), command, bashProcess, status: 'running', lastOutputTime: Date.now(), inactivityTimeout }); // Handle immediate backgrounding if inactivityTimeout is 0 if (inactivityTimeout === 0) { console.error(`inactivityTimeout is 0, backgrounding immediately with ID: ${processId}`); resolve({ content: [{ type: "text" as const, text: `Command started in background (inactivityTimeout: 0).\nProcess ID: ${processId}\nUse check_process tool to monitor status.` }] }); return; } let stdout = ""; let stderr = ""; let completed = false; const uniqueMarker = `__MCP_END_${Date.now()}_${Math.random().toString(36).substring(2)}__`; const formatResult = (exitCode: number) => { let result = ""; const cleanStdout = stdout.replace(new RegExp(`${uniqueMarker}.*`, 's'), '').trim(); const cleanStderr = stderr.trim(); if (cleanStdout && cleanStderr) { result = `STDOUT:\n${cleanStdout}\n\nSTDERR:\n${cleanStderr}`; } else if (cleanStdout) { result = cleanStdout; } else if (cleanStderr) { result = cleanStderr; } else { if (exitCode === 0) { result = "Command executed successfully (no output)"; } else { result = "Command executed with error (no output)"; } } result += `\nExit code: ${exitCode}`; return result; }; const cleanup = () => { if (!completed) { completed = true; bashProcess.stdout?.removeAllListeners("data"); bashProcess.stderr?.removeAllListeners("data"); bashProcess.removeAllListeners("error"); bashProcess.removeAllListeners("exit"); } }; // Smart timeout system: wait for output inactivity let lastOutputTime = Date.now(); let inactivityTimeoutId: NodeJS.Timeout; let maxTimeoutId: NodeJS.Timeout; const handleInactivity = () => { if (!completed) { completed = true; const inactivityDuration = Date.now() - lastOutputTime; console.error(`Command inactive for ${Math.floor(inactivityDuration/1000)}s (inactivityTimeout: ${inactivityTimeout}s), running in background with ID: ${processId}`); let result = ""; if (stdout.trim() || stderr.trim()) { const cleanStdout = stdout.trim(); const cleanStderr = stderr.trim(); if (cleanStdout && cleanStderr) { result = `STDOUT:\n${cleanStdout}\n\nSTDERR:\n${cleanStderr}\n\n`; } else if (cleanStdout) { result = `${cleanStdout}\n\n`; } else if (cleanStderr) { result = `${cleanStderr}\n\n`; } } result += `Command still running in background (no output for ${Math.floor(inactivityDuration/1000)}s, inactivityTimeout: ${inactivityTimeout}s).\nProcess ID: ${processId}\nUse check_process tool to monitor status.`; resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); } }; const resetInactivityTimer = () => { clearTimeout(inactivityTimeoutId); lastOutputTime = Date.now(); inactivityTimeoutId = setTimeout(handleInactivity, inactivityTimeout * 1000); // inactivityTimeout seconds of inactivity }; // Start inactivity timer resetInactivityTimer(); // Maximum safety timeout (10 minutes) maxTimeoutId = setTimeout(() => { if (!completed) { completed = true; console.error(`Command maximum timeout (10min) reached, running in background with ID: ${processId}`); let result = ""; if (stdout.trim() || stderr.trim()) { const cleanStdout = stdout.trim(); const cleanStderr = stderr.trim(); if (cleanStdout && cleanStderr) { result = `STDOUT:\n${cleanStdout}\n\nSTDERR:\n${cleanStderr}\n\n`; } else if (cleanStdout) { result = `${cleanStdout}\n\n`; } else if (cleanStderr) { result = `${cleanStderr}\n\n`; } } result += `Command still running in background (maximum timeout reached).\nProcess ID: ${processId}\nUse check_process tool to monitor status.`; resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); } }, 600000); // 10 minutes // Track marker detection for proper exit code handling let markerDetected = false; let detectedExitCode = 0; bashProcess.on("error", (error) => { clearTimeout(inactivityTimeoutId); clearTimeout(maxTimeoutId); cleanup(); const errorResult = `Error spawning process: ${error.message}\nExit code: 1`; // Store error result const processInfo = processes.get(processId); if (processInfo) { processInfo.status = 'completed'; processInfo.endTime = Date.now(); processInfo.exitCode = 1; processInfo.result = errorResult; processInfo.bashProcess = undefined; // Clear process reference } resolve({ content: [{ type: "text" as const, text: errorResult }] }); }); // Background process exit detection (event-driven, no polling) bashProcess.on("exit", (code) => { // Use detectedExitCode if marker was found, otherwise use bash exit code const exitCode = markerDetected ? detectedExitCode : (code ?? 1); const result = formatResult(exitCode); const processInfo = processes.get(processId); if (processInfo) { const duration = Date.now() - processInfo.startTime; console.error(`Background process ${processId} finished after ${duration}ms with exit code: ${exitCode}`); // Debug log final result const truncatedResult = result.length > 500 ? result.substring(0, 500) + '... (truncated)' : result; console.error(`[DEBUG] Process ${processId} Final Result: ${JSON.stringify(truncatedResult)}`); // Store completion result (truncated for later retrieval via check_process) processInfo.status = 'completed'; processInfo.endTime = Date.now(); processInfo.exitCode = exitCode; processInfo.result = truncateOutput(result); processInfo.bashProcess = undefined; // Clear process reference } if (!completed) { clearTimeout(inactivityTimeoutId); clearTimeout(maxTimeoutId); cleanup(); resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); } }); bashProcess.stdout.on("data", (data) => { const text = data.toString(); stdout += text; // Debug log for stdout const truncatedText = text.length > 500 ? text.substring(0, 500) + '... (truncated)' : text; console.error(`[DEBUG] Process ${processId} STDOUT: ${JSON.stringify(truncatedText)}`); // Reset inactivity timer on any output resetInactivityTimer(); // Update current output in process info const processInfo = processes.get(processId); if (processInfo) { processInfo.currentStdout = stdout; processInfo.lastOutputTime = Date.now(); } if (text.includes(uniqueMarker) && !markerDetected) { // Command completed, close stdin so bash can exit markerDetected = true; bashProcess.stdin.end(); clearTimeout(inactivityTimeoutId); clearTimeout(maxTimeoutId); const parts = stdout.split(uniqueMarker); const exitCodeMatch = parts[1]?.match(/EXIT_CODE:(\d+)/); detectedExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : 0; // For synchronous commands, let the exit handler format the final result // to ensure stderr is fully received. For backgrounded commands, store now. if (completed) { // Process was already backgrounded, format and store result now const result = formatResult(detectedExitCode); const processInfo = processes.get(processId); if (processInfo) { processInfo.status = 'completed'; processInfo.endTime = Date.now(); processInfo.exitCode = detectedExitCode; processInfo.result = truncateOutput(result); processInfo.currentStdout = parts[0].trim(); processInfo.bashProcess = undefined; } cleanup(); } // If not completed (synchronous), let exit handler finish } }); bashProcess.stderr.on("data", (data) => { const text = data.toString(); stderr += text; // Debug log for stderr const truncatedText = text.length > 500 ? text.substring(0, 500) + '... (truncated)' : text; console.error(`[DEBUG] Process ${processId} STDERR: ${JSON.stringify(truncatedText)}`); // Reset inactivity timer on any output resetInactivityTimer(); // Update current output in process info const processInfo = processes.get(processId); if (processInfo) { processInfo.currentStderr = stderr; processInfo.lastOutputTime = Date.now(); } }); // Handle background commands and heredocs properly const isBackgroundCommand = command.trim().endsWith('&'); const hasHeredoc = /<<[-]?\s*['"]?\w+['"]?/.test(command); let commandWithMarker; if (isBackgroundCommand) { // For background commands, we need to capture the PID and wait for completion differently // Don't redirect stdin for background commands - they may need it for send_input commandWithMarker = `${command} echo "${uniqueMarker}EXIT_CODE:$?"\n`; } else if (hasHeredoc) { // For heredoc commands, use newline (semicolon breaks heredoc syntax) // Don't redirect stdin for heredocs - they provide their own stdin commandWithMarker = `${command}\necho "${uniqueMarker}EXIT_CODE:$?"\n`; } else { // For regular commands: redirect stdin to /dev/null to prevent blocking on commands // that try to read stdin (like rg, grep, cat without arguments). // This is safe because: // 1) Bash stdin stays open (so send_input still works for backgrounded processes) // 2) The command itself gets /dev/null as stdin (so it doesn't block) // 3) Interactive commands are automatically backgrounded (due to timeout), then send_input is used commandWithMarker = `${command} </dev/null; echo "${uniqueMarker}EXIT_CODE:$?"\n`; } console.error(`[DEBUG] Process ${processId} sending command to bash: ${JSON.stringify(command)}`); bashProcess.stdin.write(commandWithMarker); // Keep stdin open for potential future input (don't call bashProcess.stdin.end()) }); } ); } if (shouldRegisterTool("check_process")) { server.registerTool( "check_process", { title: "Check Background Process", description: "Check the status of a background process by its ID", inputSchema: { processId: z.string().describe("The process ID returned by a long-running command") } }, async ({ processId }) => { console.error(`Checking process status: ${processId}`); const processInfo = processes.get(processId); if (!processInfo) { return { content: [{ type: "text" as const, text: "Process not found" }] }; } // If process is already completed, return results immediately if (processInfo.status === 'completed') { const duration = (processInfo.endTime! - processInfo.startTime); const durationSeconds = Math.floor(duration / 1000); let result = `Process Status: COMPLETED\n`; result += `Process ID: ${processId}\n`; result += `Command: ${processInfo.command}\n`; result += `Completed after: ${durationSeconds} seconds\n`; result += `Exit code: ${processInfo.exitCode}\n\n`; result += `Final Result:\n${processInfo.result}`; return { content: [{ type: "text" as const, text: truncateOutput(result) }] }; } // Process is running - use smart waiting based on output activity return new Promise((resolve) => { const startWaitTime = Date.now(); const initialLastOutputTime = processInfo.lastOutputTime || processInfo.startTime; console.error(`Checking process ${processId}, waiting for completion or inactivity...`); const checkCompletion = () => { const currentProcessInfo = processes.get(processId); if (!currentProcessInfo) { resolve({ content: [{ type: "text" as const, text: "Process not found (may have been cleaned up)" }] }); return; } if (currentProcessInfo.status === 'completed') { const duration = (currentProcessInfo.endTime! - currentProcessInfo.startTime); const durationSeconds = Math.floor(duration / 1000); const waitDuration = Date.now() - startWaitTime; console.error(`Process ${processId} completed during wait (waited ${waitDuration}ms)`); let result = `Process Status: COMPLETED\n`; result += `Process ID: ${processId}\n`; result += `Command: ${currentProcessInfo.command}\n`; result += `Completed after: ${durationSeconds} seconds\n`; result += `Exit code: ${currentProcessInfo.exitCode}\n\n`; result += `Final Result:\n${currentProcessInfo.result}`; resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); return; } const now = Date.now(); const lastOutput = currentProcessInfo.lastOutputTime || currentProcessInfo.startTime; const timeSinceLastOutput = now - lastOutput; const totalWaitTime = now - startWaitTime; // Return if no output for inactivityTimeout seconds OR we've waited 10 minutes total const maxWaitMs = (currentProcessInfo.inactivityTimeout || 20) * 1000; if (timeSinceLastOutput >= maxWaitMs || totalWaitTime >= 600000) { const totalDuration = now - currentProcessInfo.startTime; const totalDurationSeconds = Math.floor(totalDuration / 1000); const inactivitySeconds = Math.floor(timeSinceLastOutput / 1000); const waitSeconds = Math.floor(totalWaitTime / 1000); const reason = totalWaitTime >= 600000 ? `maximum wait time (${waitSeconds}s)` : `no output for ${inactivitySeconds}s (inactivityTimeout: ${currentProcessInfo.inactivityTimeout}s)`; console.error(`Process ${processId} still running after ${reason}`); let result = `Process Status: RUNNING\n`; result += `Process ID: ${processId}\n`; result += `Command: ${currentProcessInfo.command}\n`; result += `Running for: ${totalDurationSeconds} seconds\n`; result += `(Waited ${waitSeconds}s, ${reason})\n\n`; // Show current output if (currentProcessInfo.currentStdout || currentProcessInfo.currentStderr) { result += `Current Output:\n`; if (currentProcessInfo.currentStdout && currentProcessInfo.currentStderr) { result += `STDOUT:\n${currentProcessInfo.currentStdout.trim()}\n\nSTDERR:\n${currentProcessInfo.currentStderr.trim()}`; } else if (currentProcessInfo.currentStdout) { result += currentProcessInfo.currentStdout.trim(); } else if (currentProcessInfo.currentStderr) { result += currentProcessInfo.currentStderr.trim(); } } else { result += `No output captured yet`; } resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); return; } // Check again in 500ms setTimeout(checkCompletion, 500); }; // Start checking checkCompletion(); }); } ); } if (shouldRegisterTool("send_input")) { server.registerTool( "send_input", { title: "Send Input to Process", description: "Send input to a running background process", inputSchema: { processId: z.string().describe("The process ID of the running process"), input: z.string().describe("The input to send to the process"), autoNewline: z.boolean().optional().default(true).describe("Whether to automatically add a newline (default: true)") } }, async ({ processId, input, autoNewline = true }) => { const processInfo = processes.get(processId); if (!processInfo) { return { content: [{ type: "text" as const, text: "Process not found" }] }; } if (processInfo.status === 'completed') { return { content: [{ type: "text" as const, text: "Cannot send input to completed process" }] }; } if (!processInfo.bashProcess || !processInfo.bashProcess.stdin) { return { content: [{ type: "text" as const, text: "Process stdin not available" }] }; } try { const inputToSend = input + (autoNewline ? '\n' : ''); processInfo.bashProcess.stdin.write(inputToSend); console.error(`Sent input to process ${processId}: ${JSON.stringify(inputToSend)}`); return { content: [{ type: "text" as const, text: `Input sent to process ${processId}` }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text" as const, text: `Error sending input: ${errorMessage}` }] }; } } ); } if (shouldRegisterTool("file_ls")) { server.registerTool( "file_ls", { title: "List Directory", description: `List files and directories. Use paths relative to ${WORKSPACE}.`, inputSchema: { path: z.string().optional().default(".").describe("Directory path relative to workspace (default: .)"), ignore: z.array(z.string()).optional().describe("Glob patterns to ignore") } }, async ({ path = ".", ignore = [] }) => { await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Listing directory: ${path}${ignore.length ? ` (ignoring: ${ignore.join(', ')})` : ''}`); // Default ignore patterns const defaultIgnorePatterns = [ "node_modules/**", ".git/**", "__pycache__/**", "dist/**", "build/**", "target/**", ".next/**", ".nuxt/**", ".vscode/**", ".idea/**", "coverage/**", ".cache/**", "*.pyc", ".DS_Store" ]; return new Promise((resolve) => { // Build ripgrep arguments for file listing const rgArgs = ["--files", "--sort=path"]; // Add default ignore patterns for (const pattern of defaultIgnorePatterns) { rgArgs.push("--glob", `!${pattern}`); } // Add custom ignore patterns for (const pattern of ignore) { rgArgs.push("--glob", `!${pattern}`); } rgArgs.push(path); const rgProcess = spawn("rg", rgArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); let stdout = ""; let stderr = ""; const timeoutId = setTimeout(() => { rgProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: List operation timed out\nExit code: 1" }] }); }, 30000); rgProcess.stdout.on("data", (data) => { stdout += data.toString(); }); rgProcess.stderr.on("data", (data) => { stderr += data.toString(); }); rgProcess.on("error", (error) => { clearTimeout(timeoutId); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); rgProcess.on("close", (code) => { clearTimeout(timeoutId); // Check for errors if (code && code > 1) { resolve({ content: [{ type: "text" as const, text: stderr.trim() || `Error: Directory ${path} not found\nExit code: ${code}` }] }); return; } // Parse file list const files = stdout.trim().split('\n').filter(f => f.length > 0); if (files.length === 0) { resolve({ content: [{ type: "text" as const, text: "Directory is empty" }] }); return; } // Build tree structure interface TreeNode { name: string; isDir: boolean; children: Map<string, TreeNode>; } const root: TreeNode = { name: path, isDir: true, children: new Map() }; const maxFiles = 100; const limitedFiles = files.slice(0, maxFiles); const wasLimited = files.length > maxFiles; // Build tree from file paths for (const filePath of limitedFiles) { const parts = filePath.split('/'); let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; if (!current.children.has(part)) { current.children.set(part, { name: part, isDir: !isLast, children: new Map() }); } current = current.children.get(part)!; } } // Format tree as string const formatTree = (node: TreeNode, prefix: string = "", isLast: boolean = true, isRoot: boolean = true): string => { let result = ""; if (isRoot) { result += node.name + "/\n"; } else { const connector = isLast ? "└── " : "├── "; result += prefix + connector + node.name + (node.isDir ? "/" : "") + "\n"; } const children = Array.from(node.children.values()).sort((a, b) => { // Directories first, then alphabetically if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); children.forEach((child, index) => { const newPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ "); result += formatTree(child, newPrefix, index === children.length - 1, false); }); return result; }; let result = formatTree(root); result += `\nFound ${wasLimited ? maxFiles : files.length} file${files.length !== 1 ? 's' : ''}`; if (wasLimited) { result += ` (showing first ${maxFiles} of ${files.length}, use more specific path to see more)`; } resolve({ content: [{ type: "text" as const, text: result }] }); }); }); } ); } if (shouldRegisterTool("file_grep")) { server.registerTool( "file_grep", { title: "Search File Contents", description: `Search for regex patterns in files within ${WORKSPACE}.`, inputSchema: { pattern: z.string().describe("Search pattern (supports regex)"), path: z.string().optional().default(".").describe("Directory to search (default: .)"), include: z.string().optional().describe("File pattern to include (e.g., '*.js')"), caseInsensitive: z.boolean().optional().default(false).describe("Case insensitive search"), maxResults: z.number().optional().default(100).describe("Maximum results (default: 100)") } }, async ({ pattern, path = ".", include, caseInsensitive = false, maxResults = 100 }) => { await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Searching for pattern: ${pattern} in ${path}${include ? ` (include: ${include})` : ''}`); return new Promise((resolve) => { // Build ripgrep arguments const rgArgs = [ "-n", // Show line numbers "-H", // Show filenames "--field-match-separator=|", // Use | as separator for easier parsing "--sort=modified", // Sort by modification time (newest first) ]; if (caseInsensitive) { rgArgs.push("-i"); } if (include) { rgArgs.push("--glob", include); } rgArgs.push("--regexp", pattern); rgArgs.push(path); const rgProcess = spawn("rg", rgArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); let stdout = ""; let stderr = ""; const timeoutId = setTimeout(() => { rgProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: Search operation timed out\nExit code: 1" }] }); }, 30000); // 30 second timeout rgProcess.stdout.on("data", (data) => { stdout += data.toString(); }); rgProcess.stderr.on("data", (data) => { stderr += data.toString(); }); rgProcess.on("error", (error) => { clearTimeout(timeoutId); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); rgProcess.on("close", (code) => { clearTimeout(timeoutId); // ripgrep exit codes: 0 = matches found, 1 = no matches, 2 = error if (code === 1) { resolve({ content: [{ type: "text" as const, text: "No matches found" }] }); return; } if (code && code > 1) { resolve({ content: [{ type: "text" as const, text: stderr.trim() || "Search operation failed\nExit code: " + code }] }); return; } // Parse ripgrep output and group by file const lines = stdout.trim().split('\n').filter(line => line.length > 0); if (lines.length === 0) { resolve({ content: [{ type: "text" as const, text: "No matches found" }] }); return; } // Group matches by file const fileMatches = new Map<string, Array<{ lineNum: string; content: string }>>(); let totalMatches = 0; for (const line of lines) { // Format: filename|linenum|content (due to --field-match-separator=|) const firstPipe = line.indexOf('|'); const secondPipe = line.indexOf('|', firstPipe + 1); if (firstPipe === -1 || secondPipe === -1) continue; const filename = line.substring(0, firstPipe); const lineNum = line.substring(firstPipe + 1, secondPipe); const content = line.substring(secondPipe + 1); if (!fileMatches.has(filename)) { fileMatches.set(filename, []); } // Truncate long lines const truncatedContent = content.length > 200 ? content.substring(0, 200) + '...' : content; fileMatches.get(filename)!.push({ lineNum, content: truncatedContent }); totalMatches++; // Stop if we've reached maxResults if (totalMatches >= maxResults) break; } // Format output grouped by file let result = ""; const fileCount = fileMatches.size; for (const [filename, matches] of fileMatches) { result += `${filename} (${matches.length} match${matches.length > 1 ? 'es' : ''}):\n`; for (const match of matches) { result += ` ${match.lineNum.padStart(5)}| ${match.content}\n`; } result += '\n'; } // Add summary const wasLimited = lines.length > maxResults; result += `Found ${totalMatches} match${totalMatches !== 1 ? 'es' : ''} in ${fileCount} file${fileCount !== 1 ? 's' : ''}`; if (wasLimited) { result += ` (showing first ${maxResults}, use more specific pattern to narrow results)`; } resolve({ content: [{ type: "text" as const, text: truncateOutput(result) }] }); }); }); } ); } if (shouldRegisterTool("file_glob")) { server.registerTool( "file_glob", { title: "Find Files", description: `Find files by glob pattern (e.g., "**/*.js") within ${WORKSPACE}.`, inputSchema: { pattern: z.string().describe("Glob pattern (e.g., \"**/*.js\", \"src/**/*.ts\")"), path: z.string().optional().default(".").describe("Directory to search (default: .)"), maxResults: z.number().optional().default(100).describe("Maximum files to return (default: 100)") } }, async ({ pattern, path = ".", maxResults = 100 }) => { await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Finding files matching: ${pattern} in ${path}`); return new Promise((resolve) => { // Use ripgrep's file finding with glob pattern, sorted by modification time const rgArgs = [ "--files", "--glob", pattern, "--sort=modified", path ]; const rgProcess = spawn("rg", rgArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); let stdout = ""; let stderr = ""; const timeoutId = setTimeout(() => { rgProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: Glob operation timed out\nExit code: 1" }] }); }, 30000); // 30 second timeout rgProcess.stdout.on("data", (data) => { stdout += data.toString(); }); rgProcess.stderr.on("data", (data) => { stderr += data.toString(); }); rgProcess.on("error", (error) => { clearTimeout(timeoutId); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); rgProcess.on("close", (code) => { clearTimeout(timeoutId); // ripgrep exit codes: 0 = files found, 1 = no files, 2 = error if (code === 1 || !stdout.trim()) { resolve({ content: [{ type: "text" as const, text: "No files found" }] }); return; } if (code && code > 1) { resolve({ content: [{ type: "text" as const, text: stderr.trim() || "Glob operation failed\nExit code: " + code }] }); return; } // Parse file list and apply limit const files = stdout.trim().split('\n').filter(f => f.length > 0); const totalFiles = files.length; const limitedFiles = files.slice(0, maxResults); const wasLimited = totalFiles > maxResults; // Format output let result = limitedFiles.join('\n'); result += '\n\n'; result += `Found ${wasLimited ? maxResults : totalFiles} file${totalFiles !== 1 ? 's' : ''}`; if (wasLimited) { result += ` (showing first ${maxResults} of ${totalFiles}, use more specific pattern to narrow results)`; } resolve({ content: [{ type: "text" as const, text: result }] }); }); }); } ); } if (shouldRegisterTool("file_write")) { server.registerTool( "file_write", { title: "Write File", description: `Create or overwrite a file. Use paths relative to ${WORKSPACE}. Read the file first before writing.`, inputSchema: { filePath: z.string().describe("File path relative to workspace"), content: z.string().describe("Content to write") } }, async ({ filePath, content }) => { await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Writing file: ${filePath} (${content.length} characters)`); return new Promise((resolve) => { const bashProcess = spawn("bash", [], { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); if (!bashProcess.stdin || !bashProcess.stdout || !bashProcess.stderr) { resolve({ content: [{ type: "text" as const, text: "Error: Failed to create process streams\nExit code: 1" }] }); return; } let stdout = ""; let stderr = ""; let completed = false; const uniqueMarker = `__WRITE_END_${Date.now()}_${Math.random().toString(36).substring(2)}__`; const cleanup = () => { if (!completed) { completed = true; bashProcess.stdout?.removeAllListeners("data"); bashProcess.stderr?.removeAllListeners("data"); bashProcess.removeAllListeners("error"); bashProcess.removeAllListeners("exit"); } }; const timeoutId = setTimeout(() => { if (!completed) { completed = true; cleanup(); bashProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: Write operation timed out\nExit code: 1" }] }); } }, 30000); // 30 second timeout bashProcess.on("error", (error) => { clearTimeout(timeoutId); cleanup(); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); bashProcess.on("exit", (code) => { if (!completed) { clearTimeout(timeoutId); cleanup(); completed = true; const exitCode = code ?? 1; let result = ""; if (stdout.trim() || stderr.trim()) { const cleanStdout = stdout.replace(new RegExp(`${uniqueMarker}.*`, 's'), '').trim(); const cleanStderr = stderr.trim(); if (exitCode === 0) { result = cleanStdout || "File written successfully"; } else if (cleanStderr) { result = cleanStderr; } else if (cleanStdout) { result = cleanStdout; } } if (exitCode !== 0) { result = result || "Write operation failed"; result += `\nExit code: ${exitCode}`; } resolve({ content: [{ type: "text" as const, text: result }] }); } }); bashProcess.stdout.on("data", (data) => { const text = data.toString(); stdout += text; if (text.includes(uniqueMarker) && !completed) { bashProcess.stdin.end(); clearTimeout(timeoutId); const parts = stdout.split(uniqueMarker); const exitCodeMatch = parts[1]?.match(/EXIT_CODE:(\d+)/); const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : 0; cleanup(); completed = true; let result = parts[0].trim(); const cleanStderr = stderr.trim(); if (exitCode === 0) { result = result || "File written successfully"; resolve({ content: [{ type: "text" as const, text: result }] }); } else { result = cleanStderr || result || "Write operation failed"; result += `\nExit code: ${exitCode}`; resolve({ content: [{ type: "text" as const, text: result }] }); } } }); bashProcess.stderr.on("data", (data) => { stderr += data.toString(); }); // Escape content for safe transmission const escapedContent = content.replace(/'/g, "'\"'\"'"); // Create a bash script to write the file const writeScript = ` # Create directory if it doesn't exist mkdir -p "$(dirname "${filePath}")" || { echo "Error: Could not create directory for ${filePath}" echo "${uniqueMarker}EXIT_CODE:1" exit 1 } # Write content to file using cat with here-doc to handle all content types safely cat > "${filePath}" << 'EOF_CONTENT_MARKER' ${content} EOF_CONTENT_MARKER # Check if write was successful if [ $? -eq 0 ]; then echo "File written successfully: ${filePath}" echo "Content length: ${content.length} characters" echo "${uniqueMarker}EXIT_CODE:0" else echo "Error: Failed to write file ${filePath}" echo "${uniqueMarker}EXIT_CODE:1" fi `; bashProcess.stdin.write(writeScript); }); } ); } if (shouldRegisterTool("file_read")) { server.registerTool( "file_read", { title: "Read File", description: `Read a file's contents. Use paths relative to ${WORKSPACE}. Supports offset and limit for large files.`, inputSchema: { filePath: z.string().describe("File path relative to workspace"), offset: z.number().optional().default(0).describe("Starting line number (0-based)"), limit: z.number().optional().default(2000).describe("Max lines to read (default: 2000)") } }, async ({ filePath, offset = 0, limit = 2000 }) => { await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Reading file: ${filePath} (offset: ${offset}, limit: ${limit})`); return new Promise((resolve) => { const bashProcess = spawn("bash", [], { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); if (!bashProcess.stdin || !bashProcess.stdout || !bashProcess.stderr) { resolve({ content: [{ type: "text" as const, text: "Error: Failed to create process streams\nExit code: 1" }] }); return; } let stdout = ""; let stderr = ""; let completed = false; const uniqueMarker = `__READ_END_${Date.now()}_${Math.random().toString(36).substring(2)}__`; const cleanup = () => { if (!completed) { completed = true; bashProcess.stdout?.removeAllListeners("data"); bashProcess.stderr?.removeAllListeners("data"); bashProcess.removeAllListeners("error"); bashProcess.removeAllListeners("exit"); } }; const timeoutId = setTimeout(() => { if (!completed) { completed = true; cleanup(); bashProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: Read operation timed out\nExit code: 1" }] }); } }, 30000); // 30 second timeout bashProcess.on("error", (error) => { clearTimeout(timeoutId); cleanup(); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); bashProcess.on("exit", (code) => { if (!completed) { clearTimeout(timeoutId); cleanup(); completed = true; const exitCode = code ?? 1; const rawContent = stdout.replace(new RegExp(`${uniqueMarker}.*`, 's'), ''); const cleanStderr = stderr.trim(); if (exitCode === 0 && rawContent) { // Success - format line numbers const lines = rawContent.endsWith('\n') ? rawContent.slice(0, -1).split('\n') : rawContent.split('\n'); const formatted = lines.map((line, index) => { const lineNum = (index + offset + 1).toString().padStart(5, ' '); return `${lineNum}| ${line}`; }).join('\n'); resolve({ content: [{ type: "text" as const, text: truncateOutput(formatted) }] }); } else { // Error - return error message let result = cleanStderr || rawContent.trim() || "Read operation failed"; if (exitCode !== 0) { result += `\nExit code: ${exitCode}`; } resolve({ content: [{ type: "text" as const, text: result }] }); } } }); bashProcess.stdout.on("data", (data) => { const text = data.toString(); stdout += text; if (text.includes(uniqueMarker) && !completed) { bashProcess.stdin.end(); clearTimeout(timeoutId); const parts = stdout.split(uniqueMarker); const exitCodeMatch = parts[1]?.match(/EXIT_CODE:(\d+)/); const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : 0; cleanup(); completed = true; const rawContent = parts[0]; const cleanStderr = stderr.trim(); if (exitCode === 0) { // Success - format line numbers and return file contents // Split by newline, but handle trailing newline from bash output const lines = rawContent.endsWith('\n') ? rawContent.slice(0, -1).split('\n') : rawContent.split('\n'); const formatted = lines.map((line, index) => { const lineNum = (index + offset + 1).toString().padStart(5, ' '); return `${lineNum}| ${line}`; }).join('\n'); resolve({ content: [{ type: "text" as const, text: truncateOutput(formatted) }] }); } else { // Error - return error message let result = cleanStderr || rawContent.trim() || "Read operation failed"; result += `\nExit code: ${exitCode}`; resolve({ content: [{ type: "text" as const, text: result }] }); } } }); bashProcess.stderr.on("data", (data) => { stderr += data.toString(); }); // Create a bash script to read the file with offset and limit const readScript = ` # Check if file exists if [ ! -f "${filePath}" ]; then echo "Error: File ${filePath} not found" echo "${uniqueMarker}EXIT_CODE:1" exit 1 fi # Check if file is readable if [ ! -r "${filePath}" ]; then echo "Error: File ${filePath} is not readable" echo "${uniqueMarker}EXIT_CODE:1" exit 1 fi # Check if it's a binary file if file "${filePath}" | grep -q "binary"; then echo "Error: Cannot read binary file ${filePath}" echo "${uniqueMarker}EXIT_CODE:1" exit 1 fi # Read file with offset and limit, truncate long lines (line numbers added by TypeScript) tail -n +$((${offset} + 1)) "${filePath}" | head -n ${limit} | cut -c1-2000 echo "${uniqueMarker}EXIT_CODE:0" `; bashProcess.stdin.write(readScript); }); } ); } if (shouldRegisterTool("file_edit")) { server.registerTool( "file_edit", { title: "Edit File", description: `Replace text in a file using exact string matching. Use paths relative to ${WORKSPACE}. Read the file first to get exact text to match.`, inputSchema: { filePath: z.string().describe("File path relative to workspace"), oldString: z.string().describe("Exact text to replace"), newString: z.string().describe("Replacement text"), replaceAll: z.boolean().optional().default(false).describe("Replace all occurrences") } }, async ({ filePath, oldString, newString, replaceAll = false }) => { if (oldString === newString) { return { content: [{ type: "text" as const, text: "Error: oldString and newString must be different" }] }; } await ensureWorkspaceExists(); const workspacePath = getWorkspacePath(); console.error(`Editing file: ${filePath}`); console.error(`Replacing "${oldString.substring(0, 100)}${oldString.length > 100 ? '...' : ''}" with "${newString.substring(0, 100)}${newString.length > 100 ? '...' : ''}"`); return new Promise((resolve) => { const bashProcess = spawn("bash", [], { stdio: ["pipe", "pipe", "pipe"], cwd: workspacePath }); if (!bashProcess.stdin || !bashProcess.stdout || !bashProcess.stderr) { resolve({ content: [{ type: "text" as const, text: "Error: Failed to create process streams\nExit code: 1" }] }); return; } let stdout = ""; let stderr = ""; let completed = false; const uniqueMarker = `__EDIT_END_${Date.now()}_${Math.random().toString(36).substring(2)}__`; const cleanup = () => { if (!completed) { completed = true; bashProcess.stdout?.removeAllListeners("data"); bashProcess.stderr?.removeAllListeners("data"); bashProcess.removeAllListeners("error"); bashProcess.removeAllListeners("exit"); } }; const timeoutId = setTimeout(() => { if (!completed) { completed = true; cleanup(); bashProcess.kill(); resolve({ content: [{ type: "text" as const, text: "Error: Edit operation timed out\nExit code: 1" }] }); } }, 30000); // 30 second timeout bashProcess.on("error", (error) => { clearTimeout(timeoutId); cleanup(); resolve({ content: [{ type: "text" as const, text: `Error spawning process: ${error.message}\nExit code: 1` }] }); }); bashProcess.on("exit", (code) => { if (!completed) { clearTimeout(timeoutId); cleanup(); completed = true; const exitCode = code ?? 1; let result = ""; if (stdout.trim() || stderr.trim()) { const cleanStdout = stdout.replace(new RegExp(`${uniqueMarker}.*`, 's'), '').trim(); const cleanStderr = stderr.trim(); if (cleanStdout && cleanStderr) { result = `STDOUT:\n${cleanStdout}\n\nSTDERR:\n${cleanStderr}`; } else if (cleanStdout) { result = cleanStdout; } else if (cleanStderr) { result = cleanStderr; } } if (exitCode === 0) { result = result || "File edited successfully"; } else { result = result || "Edit operation failed"; } result += `\nExit code: ${exitCode}`; resolve({ content: [{ type: "text" as const, text: result }] }); } }); bashProcess.stdout.on("data", (data) => { const text = data.toString(); stdout += text; if (text.includes(uniqueMarker) && !completed) { bashProcess.stdin.end(); clearTimeout(timeoutId); const parts = stdout.split(uniqueMarker); const exitCodeMatch = parts[1]?.match(/EXIT_CODE:(\d+)/); const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : 0; cleanup(); completed = true; let result = ""; const cleanStdout = parts[0].trim(); const cleanStderr = stderr.trim(); if (cleanStdout && cleanStderr) { result = `STDOUT:\n${cleanStdout}\n\nSTDERR:\n${cleanStderr}`; } else if (cleanStdout) { result = cleanStdout; } else if (cleanStderr) { result = cleanStderr; } if (exitCode === 0) { result = result || "File edited successfully"; } else { result = result || "Edit operation failed"; } result += `\nExit code: ${exitCode}`; resolve({ content: [{ type: "text" as const, text: result }] }); } }); bashProcess.stderr.on("data", (data) => { stderr += data.toString(); }); // Create a Python script for safe text replacement using base64 encoding const oldStringB64 = Buffer.from(oldString).toString('base64'); const newStringB64 = Buffer.from(newString).toString('base64'); // Create a Python-based script for robust text replacement const replaceScript = ` # Check if file exists if [ ! -f "${filePath}" ]; then echo "Error: File ${filePath} not found" echo "${uniqueMarker}EXIT_CODE:1" exit 1 fi # Check if file is readable if [ ! -r "${filePath}" ]; then echo "Error: File ${filePath} is not readable" echo "${uniqueMarker}EXIT_CODE:1" exit 1 fi # Create backup cp "${filePath}" "${filePath}.backup" || { echo "Error: Could not create backup of ${filePath}" echo "${uniqueMarker}EXIT_CODE:1" exit 1 } # Use Python for safe text replacement with base64 encoding python3 -c " import sys import base64 try: # Read file content with open('${filePath}', 'r', encoding='utf-8') as f: content = f.read() # Decode base64 strings old_string = base64.b64decode('${oldStringB64}').decode('utf-8') new_string = base64.b64decode('${newStringB64}').decode('utf-8') # Check if old string exists if old_string not in content: print('Error: String not found in file') sys.exit(1) # Perform replacement if ${replaceAll ? 'True' : 'False'}: new_content = content.replace(old_string, new_string) else: new_content = content.replace(old_string, new_string, 1) # Write back to file with open('${filePath}', 'w', encoding='utf-8') as f: f.write(new_content) print('File edited successfully') sys.exit(0) except Exception as e: print(f'Error: {e}') sys.exit(1) " && { rm "${filePath}.backup" echo "${uniqueMarker}EXIT_CODE:0" } || { echo "Error: Python replacement failed, restoring backup" mv "${filePath}.backup" "${filePath}" echo "${uniqueMarker}EXIT_CODE:1" } `; bashProcess.stdin.write(replaceScript); }); } ); } /** * Configuration format for MCP server aggregation */ interface AggregatorConfig { servers: Record<string, ServerConfig>; } /** * Load MCP server configuration (baked into container at build time) */ async function loadAggregatorConfig(): Promise<AggregatorConfig> { const configPath = "/app/mcp-servers.json"; try { const configData = await readFile(configPath, "utf-8"); const config = JSON.parse(configData) as AggregatorConfig; console.error(`[Aggregator] Loaded config from ${configPath}`); console.error( `[Aggregator] Found ${Object.keys(config.servers).length} child server(s) configured` ); return config; } catch (error: any) { if (error.code === "ENOENT") { console.error( `[Aggregator] No config file found at ${configPath}, starting without child servers` ); } else { console.error(`[Aggregator] Error loading config:`, error); } return { servers: {} }; } } /** * Initialize all configured child MCP servers */ async function initializeChildServers(): Promise<void> { const config = await loadAggregatorConfig(); const serverNames = Object.keys(config.servers); if (serverNames.length === 0) { console.error("[Aggregator] No child servers to initialize"); return; } console.error(`[Aggregator] Initializing ${serverNames.length} child server(s)...`); // Start all servers in parallel const startPromises = serverNames.map(async (name) => { try { await childServerManager.startServer(name, config.servers[name]); } catch (error) { console.error(`[Aggregator] Failed to start ${name}, will continue without it`); } }); await Promise.all(startPromises); const connectedCount = childServerManager.getConnectedServers().length; console.error( `[Aggregator] Successfully connected to ${connectedCount}/${serverNames.length} child server(s)` ); } /** * Register aggregated tools from all child servers */ async function registerAggregatedTools(): Promise<void> { // Give child servers a moment to fully initialize await new Promise((resolve) => setTimeout(resolve, 1000)); console.error("[Aggregator] Discovering tools from child servers..."); const aggregatedTools = await childServerManager.aggregateTools(); if (aggregatedTools.length === 0) { console.error("[Aggregator] No tools found from child servers"); return; } console.error( `[Aggregator] Registering ${aggregatedTools.length} tool(s) from child servers` ); // Register each aggregated tool with the main server for (const tool of aggregatedTools) { server.registerTool( tool.name, // Already namespaced as "serverName__toolName" { title: tool.name, description: tool.description, inputSchema: tool.inputSchema, // Now a proper Zod schema }, async (args: any) => { // Route the tool call to the appropriate child server return await childServerManager.routeToolCall(tool.name, args); } ); } console.error(`[Aggregator] Tool registration complete`); } async function main() { console.error('='.repeat(60)); console.error('Docker MCP Server Starting'); console.error('='.repeat(60)); console.error(`Port: ${PORT}`); console.error(`Auth Token: ${AUTH_TOKEN}`); console.error(`HTTP Timeout: ${HTTP_TIMEOUT}ms (${HTTP_TIMEOUT / 1000}s)`); console.error(`Socket Timeout: ${SOCKET_TIMEOUT}ms (${SOCKET_TIMEOUT / 1000}s)`); console.error('Note: HTTP timeouts safely accommodate max command execution time (600s)'); console.error('='.repeat(60)); // Initialize child MCP servers await initializeChildServers(); // Register tools from child servers await registerAggregatedTools(); console.error('='.repeat(60)); console.error('Server initialization complete'); console.error('='.repeat(60)); // Map to store transports by session ID const transports: Record<string, StreamableHTTPServerTransport> = {}; // Helper function to parse request body const parseBody = (req: http.IncomingMessage): Promise<unknown> => { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { try { if (body) { resolve(JSON.parse(body)); } else { resolve(undefined); } } catch (error) { reject(new Error('Invalid JSON in request body')); } }); req.on('error', reject); }); }; // Create HTTP server with authentication middleware const httpServer = http.createServer(async (req, res) => { console.error(`[DEBUG] HTTP request received: ${req.method} ${req.url}`); // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id'); res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); // Handle preflight requests if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Check bearer token authentication const authHeader = req.headers['authorization']; const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; if (token !== AUTH_TOKEN) { console.error(`Authentication failed: Invalid token from ${req.socket.remoteAddress}`); res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized: Invalid or missing bearer token' })); return; } // Handle MCP requests try { const sessionId = req.headers['mcp-session-id'] as string | undefined; const executionId = req.headers['execution-id'] as string | undefined; // Log execution ID if provided if (executionId) { console.error(`[Execution-Id] Request scoped to workspace: /app/workspace/${executionId}`); } // For POST requests, parse the body let parsedBody: unknown = undefined; if (req.method === 'POST') { parsedBody = await parseBody(req); } let transport: StreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport for this session console.error(`Request for existing session: ${sessionId}`); transport = transports[sessionId]; } else if (!sessionId && req.method === 'POST' && isInitializeRequest(parsedBody)) { // New initialization request - create new transport console.error('New initialization request - creating transport'); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { console.error(`Session initialized with ID: ${newSessionId}`); transports[newSessionId] = transport; } }); // Set up onclose handler to clean up transport when closed transport.onclose = () => { const sid = transport.sessionId; if (sid && transports[sid]) { console.error(`Transport closed for session ${sid}, removing from transports map`); delete transports[sid]; } }; // Connect the transport to the MCP server BEFORE handling the request console.error('[DEBUG] Connecting transport to server...'); await server.connect(transport); console.error('[DEBUG] Transport connected, handling request...'); // Handle the request with the parsed body in the execution context await requestContext.run({ executionId }, async () => { await transport.handleRequest(req, res, parsedBody); }); console.error('[DEBUG] handleRequest completed'); return; } else { // Invalid request - no session ID or not initialization request console.error(`Invalid request: method=${req.method}, sessionId=${sessionId}, isInit=${parsedBody ? isInitializeRequest(parsedBody) : false}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided or not an initialization request', }, id: null, })); return; } // Handle the request with existing transport in the execution context await requestContext.run({ executionId }, async () => { await transport.handleRequest(req, res, parsedBody); }); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, })); } } }); // Configure HTTP server timeouts to accommodate long-running operations httpServer.timeout = HTTP_TIMEOUT; httpServer.requestTimeout = HTTP_TIMEOUT; httpServer.keepAliveTimeout = DEFAULT_KEEP_ALIVE_TIMEOUT; // Configure socket timeout for individual connections httpServer.on('connection', (socket) => { socket.setTimeout(SOCKET_TIMEOUT); }); // Start HTTP server httpServer.listen(PORT, '0.0.0.0', () => { console.error('='.repeat(60)); console.error(`Docker MCP Server started successfully`); console.error(`Listening on: http://0.0.0.0:${PORT}`); console.error(`Working directory: /app/workspace (scoped by Execution-Id header)`); console.error(`Timeouts: HTTP=${HTTP_TIMEOUT / 1000}s, Socket=${SOCKET_TIMEOUT / 1000}s (accommodates 600s max command execution)`); console.error('='.repeat(60)); console.error(''); console.error('To connect, use the following configuration:'); console.error(JSON.stringify({ url: `http://localhost:${PORT}`, headers: { Authorization: `Bearer ${AUTH_TOKEN}` } }, null, 2)); console.error('='.repeat(60)); }); // Handle server shutdown process.on('SIGINT', async () => { console.error('Shutting down server...'); // Shutdown all child servers await childServerManager.stopAll(); // Close all active transports for (const sessionId in transports) { try { console.error(`Closing transport for session ${sessionId}`); await transports[sessionId].close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); } } console.error('Server shutdown complete'); process.exit(0); }); } main().catch((error) => { console.error("Server error:", error); process.exit(1); });

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/kenforthewin/docker-mcp-server'

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