#!/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);
});