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