index.ts•15.9 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ErrorCode,
  McpError,
  TextContent,
  CallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { spawn } from "child_process";
import { z } from "zod";
import { fileURLToPath } from 'url';
// Define structure for config file
interface LanguageSetting {
  image: string;
}
interface ServerConfig {
  isolatorPath: string;
  defaultTimeoutSeconds: number;
  defaultMemoryMB: number;
  defaultCpus: number;
  defaultPidsLimit: number;
  defaultNetworkMode: string;
  defaultCapDrop: string[];
  defaultReadOnlyRootfs: boolean;
  defaultContainerWorkdir: string;
  languageSettings: {
    [key: string]: LanguageSetting;
  };
  promptsDir: string; // Renamed from promptsDir if needed, reflects config key
}
// Define structure for Go CLI JSON output
interface ExecutionResult {
	status: "success" | "timeout" | "error";
	exitCode: number;
	stdout: string;
	stderr: string;
	durationMs: number;
	error?: string;
	imageTag?: string;
}
// --- Configuration Loading ---
let serverConfig: ServerConfig;
async function loadConfig(): Promise<ServerConfig> {
  const __filename = fileURLToPath(import.meta.url);
  const __dirname = path.dirname(__filename);
  const configPath = path.resolve(__dirname, '..', 'isolator_config.json'); // Adjust path relative to build output
  try {
    const configFile = await fs.readFile(configPath, 'utf-8');
    const config = JSON.parse(configFile) as ServerConfig;
    // Basic validation
    if (!config.isolatorPath || !config.languageSettings || !config.promptsDir) { // Check promptsDir too
        throw new Error("Invalid config: missing isolatorPath, languageSettings, or promptsDir");
    }
    console.error(`Configuration loaded from ${configPath}`);
    return config;
  } catch (error) {
    console.error(`Error loading config from ${configPath}:`, error);
    throw new Error(`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`);
  }
}
// --- Tool Definition ---
const additionalFileSchema = z.object({
    filename: z.string().describe("Name of the file (including extension)"),
    content: z.string().describe("Content of the file"),
});
// Input schema allowing either direct code OR a snippet name
const executeCodeInputSchema = z.object({
  language: z.string().optional().describe("The programming language (python, go, javascript). Required unless using snippet_name."),
  entrypoint_code: z.string().optional().describe("The main code content to execute. Required unless using snippet_name."),
  entrypoint_filename: z.string().optional().describe("Optional filename for the main code (defaults based on language)"),
  additional_files: z.array(additionalFileSchema).optional().describe("Optional array of additional files needed for execution"),
  snippet_name: z.string().optional().describe("Name of a pre-defined code snippet to execute (e.g., 'hello_world'). Mutually exclusive with entrypoint_code/language.")
}).refine(data => (data.snippet_name && !data.entrypoint_code && !data.language) || (!data.snippet_name && data.entrypoint_code && data.language), {
  message: "Either 'snippet_name' or BOTH 'entrypoint_code' and 'language' must be provided, but not both sets.",
});
// --- Server Setup ---
const server = new Server(
  {
    name: "isolator-mcp",
    version: "0.1.0",
  },
  {
    capabilities: {
      tools: {}, // Indicates server provides tools
    },
  }
);
// --- Tool Handlers ---
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "execute_code",
        description: "Executes code (Python, Go, JavaScript) in a secure, isolated container environment.",
        inputSchema: {
          type: "object",
          properties: {
            language: {
              type: "string",
              description: "The programming language (python, go, javascript). Required unless using snippet_name."
            },
            entrypoint_code: {
              type: "string",
              description: "The main code content to execute. Required unless using snippet_name."
            },
            entrypoint_filename: {
              type: "string",
              description: "Optional filename for the main code (defaults based on language)."
            },
            additional_files: {
              type: "array",
              description: "Optional array of additional files needed for execution",
              items: {
                type: "object",
                properties: {
                  filename: {
                    type: "string",
                    description: "Name of the file (including extension)"
                  },
                  content: {
                    type: "string",
                    description: "Content of the file"
                  },
                },
                required: ["filename", "content"],
              },
            },
            snippet_name: {
              type: "string",
              description: "Name of a pre-defined code snippet to execute (e.g., 'hello_world'). Mutually exclusive with entrypoint_code/language.",
            },
          },
          // Rely on Zod validation in the handler for mutual exclusivity.
        },
      },
    ],
  };
});
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
  if (request.params.name !== "execute_code") {
    throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
  }
  // Validate input using Zod schema
  const parseResult = executeCodeInputSchema.safeParse(request.params.arguments);
  if (!parseResult.success) {
    console.error("Invalid input:", parseResult.error.errors);
    throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${parseResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);
  }
  let languageInput: string;
  let codeInput: string;
  let languageDetectedFromSnippet = false;
  const { entrypoint_filename, additional_files, snippet_name } = parseResult.data;
  let finalEntrypointFilename: string;
  // --- Determine code input and language: Snippet or Direct ---
  if (snippet_name) {
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    const snippetsDir = path.resolve(__dirname, '..', serverConfig.promptsDir); // Use config value
    const snippetExt = path.extname(snippet_name);
    const baseSnippetName = snippetExt ? path.basename(snippet_name, snippetExt) : snippet_name;
    let foundSnippetPath: string | undefined;
    let detectedLang: string | undefined;
    let snippetFilename: string | undefined;
    const possibleExtensions: { [key: string]: string } = {
        ".py": "python",
        ".go": "go",
        ".js": "javascript",
        ".mjs": "javascript",
        ".cjs": "javascript",
    };
    if (snippetExt && possibleExtensions[snippetExt]) {
        detectedLang = possibleExtensions[snippetExt];
        foundSnippetPath = path.join(snippetsDir, `${baseSnippetName}${snippetExt}`);
        snippetFilename = `${baseSnippetName}${snippetExt}`;
    } else {
        for (const [ext, lang] of Object.entries(possibleExtensions)) {
             const testPath = path.join(snippetsDir, `${baseSnippetName}${ext}`);
             try {
                 await fs.access(testPath);
                 foundSnippetPath = testPath;
                 detectedLang = lang;
                 snippetFilename = `${baseSnippetName}${ext}`;
                 break;
             } catch { /* continue */ }
        }
    }
    if (!foundSnippetPath || !detectedLang) {
        throw new McpError(ErrorCode.InvalidParams, `Snippet '${snippet_name}' not found in '${snippetsDir}' or language could not be determined.`);
    }
    try {
        codeInput = await fs.readFile(foundSnippetPath, 'utf-8');
        languageInput = detectedLang;
        languageDetectedFromSnippet = true;
        console.error(`Loaded code from snippet: ${foundSnippetPath}`);
    } catch (readError) {
        throw new McpError(ErrorCode.InternalError, `Failed to read snippet file '${foundSnippetPath}': ${readError instanceof Error ? readError.message : String(readError)}`);
    }
    if (!snippetFilename) {
        throw new McpError(ErrorCode.InternalError, `Could not determine snippet filename for ${snippet_name}`);
    }
    finalEntrypointFilename = snippetFilename;
  } else {
    languageInput = parseResult.data.language!;
    codeInput = parseResult.data.entrypoint_code!;
    finalEntrypointFilename = entrypoint_filename || "";
    if (!finalEntrypointFilename) {
        switch(languageInput.toLowerCase()) {
            case "python": finalEntrypointFilename = "main.py"; break;
            case "go": finalEntrypointFilename = "main.go"; break;
            case "javascript": finalEntrypointFilename = "index.js"; break;
            default:
                throw new McpError(ErrorCode.InternalError, `Cannot determine default entrypoint filename for ${languageInput}`);
        }
    }
  }
  const langLower = languageInput.toLowerCase();
  if (!serverConfig.languageSettings[langLower]) {
      const errorMsg = languageDetectedFromSnippet
        ? `Language '${languageInput}' (detected from snippet '${snippet_name}') is not configured.`
        : `Unsupported language provided directly: ${languageInput}`;
      throw new McpError(ErrorCode.InvalidParams, errorMsg);
  }
  // --- Temporary Directory and File Creation ---
  let tempDir: string | undefined;
  try {
    tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "isolator-mcp-"));
    // Set directory permissions to be accessible (rwxr-xr-x)
    await fs.chmod(tempDir, 0o755);
    console.error(`Created temp dir: ${tempDir} with mode 755`);
    // Write entrypoint file
    const entrypointPath = path.join(tempDir, finalEntrypointFilename);
    await fs.writeFile(entrypointPath, codeInput);
    // Set file permissions to be readable (rw-r--r--)
    // Use 0o755 if script needs to be executable itself by the container user (less common)
    await fs.chmod(entrypointPath, 0o644);
    console.error(`Wrote entrypoint file: ${entrypointPath} with mode 644`);
    // Write additional files
    if (additional_files) {
      for (const file of additional_files) {
        const safeFilename = path.basename(file.filename);
        if (safeFilename !== file.filename || safeFilename === "." || safeFilename === "..") {
             throw new McpError(ErrorCode.InvalidParams, `Invalid additional filename: ${file.filename}`);
        }
        const additionalFilePath = path.join(tempDir, safeFilename);
        await fs.writeFile(additionalFilePath, file.content);
        // Set file permissions to be readable (rw-r--r--)
        await fs.chmod(additionalFilePath, 0o644);
        console.error(`Wrote additional file: ${additionalFilePath} with mode 644`);
      }
    }
    // --- Construct Go CLI Command ---
    const args: string[] = [
        "run",
        "--language", langLower,
        "--dir", tempDir,
        "--timeout", `${serverConfig.defaultTimeoutSeconds}s`,
        "--memory", String(serverConfig.defaultMemoryMB),
        "--cpus", String(serverConfig.defaultCpus),
        "--pids-limit", String(serverConfig.defaultPidsLimit),
        "--network", serverConfig.defaultNetworkMode,
        "--read-only", String(serverConfig.defaultReadOnlyRootfs),
        "--container-workdir", serverConfig.defaultContainerWorkdir,
        "--entrypoint", finalEntrypointFilename,
        ...serverConfig.defaultCapDrop.map(cap => ["--cap-drop", cap]).flat(),
    ];
    // Add GOCACHE env var specifically for Go
    if (langLower === "go") {
        args.push("--env", "GOCACHE=/tmp");
    }
    console.error(`Executing: ${serverConfig.isolatorPath} ${args.join(" ")}`);
    // --- Execute Go CLI ---
    const executionPromise = new Promise<ExecutionResult>((resolve, reject) => {
        // Use default spawn options, relying on the --env flag now
        const spawnOptions: Parameters<typeof spawn>[2] = { stdio: ["ignore", "pipe", "pipe"], env: process.env };
        const child = spawn(serverConfig.isolatorPath, args, spawnOptions);
        let stdoutData = "";
        let stderrData = "";
        if (child.stdout) {
            child.stdout.on("data", (data) => { stdoutData += data.toString(); });
        } else {
            console.error("Warning: child.stdout is null");
        }
        if (child.stderr) {
            child.stderr.on("data", (data) => {
                stderrData += data.toString();
                console.error("isolator CLI stderr:", data.toString());
            });
        } else {
             console.error("Warning: child.stderr is null");
        }
        child.on("error", (err) => {
            console.error("Failed to start isolator CLI:", err);
            reject(new Error(`Failed to start isolator process: ${err.message}`));
        });
        child.on("close", (code) => {
            console.error(`isolator CLI exited with code ${code}`);
            try {
                const result: ExecutionResult = JSON.parse(stdoutData);
                resolve(result);
            } catch (parseError) {
                 console.error("Failed to parse isolator JSON output:", parseError);
                 console.error("isolator stdout:", stdoutData);
                 resolve({
                     status: "error",
                     exitCode: code ?? -1,
                     stdout: stdoutData,
                     stderr: stderrData,
                     durationMs: 0,
                     error: `Failed to parse execution result from isolator CLI. Exit code: ${code}. ${parseError instanceof Error ? parseError.message : String(parseError)}`
                 });
            }
        });
    });
    const result = await executionPromise;
    // --- Format MCP Result ---
    const outputText = `--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`;
    const content: TextContent = { type: "text", text: outputText };
    const callResult: CallToolResult = { content: [content] };
    if (result.status !== "success") {
        callResult.isError = true;
        content.text = `Execution Failed (${result.status}): ${result.error || 'Unknown error'}\n\n` + content.text;
    }
    return callResult;
  } catch (error) {
    console.error("Error during execute_code handling:", error);
    throw new McpError(ErrorCode.InternalError, `Failed to execute code: ${error instanceof Error ? error.message : String(error)}`);
  } finally {
    // --- Cleanup Temporary Directory ---
    if (tempDir) {
      try {
        await fs.rm(tempDir, { recursive: true, force: true });
        console.error(`Cleaned up temp directory: ${tempDir}`);
      } catch (cleanupError) {
        console.error(`Error cleaning up temp directory ${tempDir}:`, cleanupError);
      }
    }
  }
});
// --- Server Startup ---
async function main() {
  try {
    serverConfig = await loadConfig(); // Load config before connecting
    server.onerror = (error) => console.error("[MCP Error]", error);
    process.on("SIGINT", async () => {
      console.error("Received SIGINT, shutting down server...");
      await server.close();
      process.exit(0);
    });
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("Isolator MCP server running on stdio");
  } catch (error) {
    console.error("Failed to start Isolator MCP server:", error);
    process.exit(1);
  }
}
main();