#!/usr/bin/env node // Stdio MCP server: sandboxes file operations to SANDBOX_ROOT. // Used as a goosed extension to replace the built-in developer extension for // regular users — enforces path boundaries at the OS level rather than relying // on AI model compliance with text constraints. // // SANDBOX_ROOT is passed as argv[2] (primary) or SANDBOX_ROOT env var (fallback). // argv[2] is preferred because some goosed versions don't forward env to stdio MCPs. // Optional env: ALLOWED_TOOLS — comma-separated tool whitelist (default: all tools) import path from 'node:path'; import fs from 'node:fs'; import readline from 'node:readline'; const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim(); if (!SANDBOX_ROOT) { process.stderr.write('[mindspace-sandbox-mcp] SANDBOX_ROOT is not set — refusing to start\n'); process.exit(1); } const SANDBOX = path.resolve(SANDBOX_ROOT); const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim(); const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null; /** Resolve and validate that the path is inside SANDBOX. Returns absolute path. */ function resolveSandboxed(p) { if (!p || typeof p !== 'string') throw new Error('路径参数无效'); const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p); if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) { throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' }); } return resolved; } const ALL_TOOLS = [ { name: 'read_file', description: '读取文件内容(仅限工作区内的文件)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: '文件路径(相对工作区或绝对路径)' }, }, required: ['path'], }, }, { name: 'write_file', description: '写入文件内容(仅限工作区内的文件;不存在则创建,已存在则覆盖)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: '文件路径' }, content: { type: 'string', description: '文件内容' }, }, required: ['path', 'content'], }, }, { name: 'edit_file', description: '将文件中的旧内容替换为新内容(仅限工作区内的文件)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: '文件路径' }, old_str: { type: 'string', description: '要替换的原始内容(必须在文件中唯一)' }, new_str: { type: 'string', description: '替换后的新内容' }, }, required: ['path', 'old_str', 'new_str'], }, }, { name: 'list_dir', description: '列出目录内容(仅限工作区内的目录)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: '目录路径(省略则列出工作区根目录)', }, }, }, }, { name: 'create_dir', description: '创建目录(仅限工作区内;已存在不报错)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: '目录路径' }, }, required: ['path'], }, }, ]; const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS; function callTool(name, args) { if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) { throw new Error(`工具 ${name} 未授权`); } switch (name) { case 'read_file': { const abs = resolveSandboxed(args.path); const content = fs.readFileSync(abs, 'utf8'); return [{ type: 'text', text: content }]; } case 'write_file': { const abs = resolveSandboxed(args.path); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, args.content ?? '', 'utf8'); return [{ type: 'text', text: `已写入 ${args.path}(${(args.content ?? '').length} 字节)` }]; } case 'edit_file': { const abs = resolveSandboxed(args.path); const original = fs.readFileSync(abs, 'utf8'); const oldStr = args.old_str ?? ''; if (oldStr && !original.includes(oldStr)) { throw new Error('edit_file: old_str 在文件中不存在,替换失败'); } const updated = oldStr ? original.replace(oldStr, args.new_str ?? '') : (args.new_str ?? ''); fs.writeFileSync(abs, updated, 'utf8'); return [{ type: 'text', text: `已编辑 ${args.path}` }]; } case 'list_dir': { const target = args?.path ?? '.'; const abs = resolveSandboxed(target); const entries = fs.readdirSync(abs, { withFileTypes: true }); const lines = entries.map((e) => `${e.isDirectory() ? '[目录]' : '[文件]'} ${e.name}`); return [{ type: 'text', text: lines.join('\n') || '(空目录)' }]; } case 'create_dir': { const abs = resolveSandboxed(args.path); fs.mkdirSync(abs, { recursive: true }); return [{ type: 'text', text: `已创建目录 ${args.path}` }]; } default: throw new Error(`未知工具:${name}`); } } // ── JSON-RPC over stdio ──────────────────────────────────────────────────── function send(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); } function respond(id, result) { send({ jsonrpc: '2.0', id, result }); } function respondError(id, message, code = -32603) { send({ jsonrpc: '2.0', id, error: { code, message } }); } const rl = readline.createInterface({ input: process.stdin, terminal: false }); rl.on('line', (raw) => { const line = raw.trim(); if (!line) return; let msg; try { msg = JSON.parse(line); } catch { process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line}\n`); return; } const { id, method, params } = msg; switch (method) { case 'initialize': respond(id, { protocolVersion: '2024-11-05', serverInfo: { name: 'mindspace-sandbox', version: '1.0.0' }, capabilities: { tools: {} }, }); break; case 'initialized': // notification — no response break; case 'tools/list': respond(id, { tools: TOOLS }); break; case 'tools/call': { const { name, arguments: toolArgs } = params ?? {}; try { const content = callTool(name, toolArgs ?? {}); respond(id, { content, isError: false }); } catch (err) { respond(id, { content: [{ type: 'text', text: err.message }], isError: true, }); } break; } default: if (id !== undefined && id !== null) { respondError(id, `Method not found: ${method}`, -32601); } } }); rl.on('close', () => process.exit(0));