#!/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'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import mysql from 'mysql2/promise'; import { createScheduleService } from './schedule-service.mjs'; import { resolveScheduleTimestamp } from './schedule-time.mjs'; import { renderLongImage } from './mindspace-long-image.mjs'; 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 SANDBOX_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace'); const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite'); const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3'; const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024); const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000); const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200); const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim(); 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' }); } if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) { throw Object.assign(new Error('禁止直接访问私有数据目录,请使用 private_data_* 工具'), { code: 'EACCES' }); } return resolved; } function resolveDocxGenerateScript() { const candidates = [ path.join(SANDBOX, '.agents', 'skills', 'docx-generate', 'generate_docx.py'), path.join(SANDBOX_MODULE_DIR, 'skills', 'docx-generate', 'generate_docx.py'), ]; for (const candidate of candidates) { if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { return candidate; } } throw new Error( 'generate_docx: 未找到 generate_docx.py,请先 load_skill → docx-generate 同步技能到工作区', ); } function normalizeDocxSections(sections) { if (!Array.isArray(sections) || sections.length === 0) { throw new Error('generate_docx: sections 必须是非空数组'); } return sections.map((section, index) => { if (!section || typeof section !== 'object') { throw new Error(`generate_docx: sections[${index}] 必须是对象`); } const normalized = { heading: section.heading != null ? String(section.heading) : '', paragraphs: Array.isArray(section.paragraphs) ? section.paragraphs.map((paragraph) => String(paragraph)) : [], }; if (section.table && typeof section.table === 'object') { normalized.table = section.table; } return normalized; }); } function runGenerateDocxScript({ outputPath, title, sections }) { const script = resolveDocxGenerateScript(); const python = process.env.PYTHON_BIN?.trim() || 'python3'; const payload = JSON.stringify({ title: String(title ?? ''), sections: normalizeDocxSections(sections), }); const stdout = execFileSync( python, [script, '--json', '-', '--output', outputPath], { cwd: SANDBOX, input: payload, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, }, ); const abs = resolveSandboxed(outputPath); if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { throw new Error(`generate_docx: 脚本执行后未找到 ${outputPath}`); } const size = fs.statSync(abs).size; if (size < 64) { throw new Error(`generate_docx: ${outputPath} 体积异常(${size} 字节)`); } return { bytes: size, stdout: String(stdout ?? '').trim() }; } 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'], }, }, { name: 'generate_long_image', description: '用 Playwright 将工作区内的 HTML 页面渲染为整页 PNG 长图。输出文件通常为 public/<页面名>.long.png,必须在工作区内。', inputSchema: { type: 'object', properties: { html_path: { type: 'string', description: 'HTML 文件路径,如 public/report.html' }, output_path: { type: 'string', description: '输出 PNG 路径,如 public/report.long.png;可选' }, }, required: ['html_path'], }, }, { name: 'generate_docx', description: '生成 Word .docx 文件并落盘到工作区(如 public/报告.docx)。公网下载页必须先调用本工具确认文件存在,再写 HTML 相对链接。', inputSchema: { type: 'object', properties: { output_path: { type: 'string', description: '输出 .docx 路径,如 public/协和智慧门诊研究摘要.docx' }, title: { type: 'string', description: '文档主标题' }, sections: { type: 'array', description: '章节数组;每项可含 heading、paragraphs、可选 table(headers/rows)', items: { type: 'object' }, }, }, required: ['output_path', 'title', 'sections'], }, }, { name: 'private_data_info', description: '查看当前用户“用户私有数据空间”的状态。每个用户只有一个私有 SQLite 数据库,适合保存问卷、表单、清单、调研数据和分析中间表;不要创建额外 SQLite 文件。', inputSchema: { type: 'object', properties: {} }, }, { name: 'private_data_schema', description: '查看用户私有数据空间中的表和字段。用于了解当前用户自己的 SQLite 表结构。', inputSchema: { type: 'object', properties: {} }, }, { name: 'private_data_query', description: '只读查询用户私有数据空间。仅允许 SELECT/WITH,默认最多返回 200 行,适合统计问卷回答、筛选表单数据、分析用户私有数据。', inputSchema: { type: 'object', properties: { sql: { type: 'string', description: '只读 SQL,必须是 SELECT/WITH' }, }, required: ['sql'], }, }, { name: 'private_data_execute', description: '写入或变更用户私有数据空间。可用于创建问卷/表单/清单等私有表并写入数据;禁止 ATTACH、load_extension、PRAGMA writable_schema、VACUUM INTO 等越界或危险操作。', inputSchema: { type: 'object', properties: { sql: { type: 'string', description: '要执行的 SQLite SQL' }, }, required: ['sql'], }, }, ]; let quotaPool = null; let scheduleService = null; function isQuotaSyncConfigured() { return Boolean( PRIVATE_DATA_USER_ID && (process.env.DATABASE_URL || (process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)), ); } function getQuotaPool() { if (!isQuotaSyncConfigured()) return null; if (quotaPool) return quotaPool; quotaPool = process.env.DATABASE_URL ? mysql.createPool(process.env.DATABASE_URL) : mysql.createPool({ host: process.env.MYSQL_HOST ?? 'localhost', port: Number(process.env.MYSQL_PORT ?? 3306), user: process.env.MYSQL_USER ?? 'boot', password: process.env.MYSQL_PASSWORD ?? '', database: process.env.MYSQL_DATABASE ?? 'tkmind', waitForConnections: true, connectionLimit: 2, }); return quotaPool; } function isScheduleConfigured() { return isQuotaSyncConfigured(); } function getScheduleService() { if (!PRIVATE_DATA_USER_ID) throw new Error('当前会话没有用户上下文,不能操作待办'); const pool = getQuotaPool(); if (!pool || !isScheduleConfigured()) { throw new Error('当前环境未配置待办数据库连接'); } if (!scheduleService) { scheduleService = createScheduleService(pool, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); } return scheduleService; } if (isScheduleConfigured()) { ALL_TOOLS.push( { name: 'schedule_create_item', description: '为当前用户创建待办或日程事项。仅写入当前用户自己的 h5_schedule_items,不可操作其他用户数据。', inputSchema: { type: 'object', properties: { kind: { type: 'string', description: 'task 或 event,默认 task' }, title: { type: 'string', description: '事项标题' }, description: { type: 'string', description: '事项描述,可选' }, startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳,可选(优先使用 startLocal)' }, startLocal: { type: 'string', description: '本地开始时间 YYYY-MM-DD HH:mm,推荐' }, endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选(优先使用 endLocal)' }, endLocal: { type: 'string', description: '本地结束时间 YYYY-MM-DD HH:mm,推荐' }, dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳,可选(优先使用 dueLocal)' }, dueLocal: { type: 'string', description: '本地截止时间 YYYY-MM-DD HH:mm,可选' }, allDay: { type: 'boolean', description: '是否全天事项,可选' }, timezone: { type: 'string', description: '时区,可选' }, location: { type: 'string', description: '地点,可选' }, sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' }, sourceText: { type: 'string', description: '原始用户文本,可选' }, }, required: ['title'], }, }, { name: 'schedule_create_reminder', description: '为当前用户已有事项创建单次提醒。必须先有 itemId,再写入 h5_schedule_reminders。', inputSchema: { type: 'object', properties: { itemId: { type: 'string', description: '事项 ID' }, remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳(优先使用 remindLocal)' }, remindLocal: { type: 'string', description: '本地提醒时间 YYYY-MM-DD HH:mm,推荐' }, offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' }, channel: { type: 'string', description: '提醒通道,默认 wechat' }, }, required: ['itemId'], }, }, { name: 'schedule_list_items', description: '查询当前用户的待办/日程事项列表。仅返回当前用户数据,可按时间范围过滤。', inputSchema: { type: 'object', properties: { from: { type: 'number', description: '起始时间 Unix 毫秒时间戳,可选' }, to: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' }, status: { type: 'string', description: '事项状态,默认 active,可选' }, limit: { type: 'number', description: '返回数量,默认 50,可选' }, }, }, }, ); } const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS; function ensurePrivateDataDb() { fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true }); if (!fs.existsSync(PRIVATE_DATA_DB)) { runSqlite(['-batch', PRIVATE_DATA_DB, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']); } return PRIVATE_DATA_DB; } function privateDataSize() { let total = 0; for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) { if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) { total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size; } } return total; } function runSqlite(args) { return execFileSync(SQLITE_BIN, args, { encoding: 'utf8', timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS, maxBuffer: 1024 * 1024, }); } function sqliteScalar(sql) { return Number(runSqlite(['-batch', '-noheader', PRIVATE_DATA_DB, sql]).trim() || 0); } async function getQuotaState({ forUpdate = false, conn = null } = {}) { const pool = getQuotaPool(); if (!pool) return null; const db = conn ?? pool; const [rows] = await db.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces WHERE user_id = ? LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`, [PRIVATE_DATA_USER_ID], ); const row = rows[0]; if (!row) return null; const quotaBytes = Number(row.quota_bytes ?? 0); const usedBytes = Number(row.used_bytes ?? 0); const reservedBytes = Number(row.reserved_bytes ?? 0); return { id: row.id, status: row.status, quotaBytes, usedBytes, reservedBytes, availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes), }; } async function sqliteMaxPagePragmaForQuota(currentBytes) { const quota = await getQuotaState(); if (!quota) return ''; if (quota.status !== 'active') throw new Error('用户空间不可写'); if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) { throw new Error('用户私有数据空间配额不足'); } const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes); const pageSize = sqliteScalar('PRAGMA page_size;') || 4096; const currentPages = sqliteScalar('PRAGMA page_count;') || 1; const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize))); return `PRAGMA max_page_count=${maxPages};`; } async function syncPrivateDataQuota(deltaBytes) { const pool = getQuotaPool(); if (!pool || !deltaBytes) return null; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const quota = await getQuotaState({ forUpdate: true, conn }); if (!quota) { await conn.rollback(); return null; } if (quota.status !== 'active') throw new Error('用户空间不可写'); if (deltaBytes > quota.availableBytes) { throw new Error(`用户私有数据空间配额不足:需要 ${deltaBytes} 字节,可用 ${quota.availableBytes} 字节`); } await conn.query( `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? WHERE id = ? AND user_id = ?`, [deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID], ); await conn.commit(); return { deltaBytes }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } function stripSqlComments(sql) { return String(sql ?? '') .replace(/--.*$/gm, '') .replace(/\/\*[\s\S]*?\*\//g, '') .trim(); } function rejectDangerousSql(sql, { readonly = false } = {}) { const cleaned = stripSqlComments(sql); if (!cleaned) throw new Error('SQL 不能为空'); if (cleaned.length > 20000) throw new Error('SQL 过长'); if (/^\s*\./m.test(cleaned)) { throw new Error('SQL 不允许使用 sqlite3 dot command'); } if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) { throw new Error('SQL 包含用户私有数据空间不允许的操作'); } if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) { throw new Error('SQL 包含不允许的 PRAGMA'); } if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) { throw new Error('private_data_query 只允许 SELECT/WITH'); } return cleaned; } async function queryPrivateData(sql) { const db = ensurePrivateDataDb(); const cleaned = rejectDangerousSql(sql, { readonly: true }); const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`; const output = runSqlite(['-json', db, limited]); return output.trim() || '[]'; } async function executePrivateData(sql) { const db = ensurePrivateDataDb(); const before = privateDataSize(); if (before > PRIVATE_DATA_MAX_BYTES) throw new Error('用户私有数据空间已超过大小限制'); const cleaned = rejectDangerousSql(sql); const quotaPragma = await sqliteMaxPagePragmaForQuota(before); runSqlite(['-batch', db, `${quotaPragma}\n${cleaned}`]); const after = privateDataSize(); if (after > PRIVATE_DATA_MAX_BYTES) { throw new Error(`用户私有数据空间超过大小限制:${after}/${PRIVATE_DATA_MAX_BYTES} 字节`); } const delta = after - before; const quotaSync = await syncPrivateDataQuota(delta); return `已执行。当前数据空间大小 ${after} 字节${quotaSync ? `,已同步空间占用 ${delta} 字节` : ''}`; } async function privateDataSchema() { const db = ensurePrivateDataDb(); const output = runSqlite([ '-json', db, `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid`, ]); return output.trim() || '[]'; } async 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}` }]; } case 'generate_long_image': { const htmlPath = String(args.html_path ?? args.path ?? '').trim(); if (!htmlPath.toLowerCase().endsWith('.html')) { throw new Error('generate_long_image: html_path 必须是 .html 文件'); } const htmlAbs = resolveSandboxed(htmlPath); const outputPath = String(args.output_path ?? '').trim() || htmlPath.replace(/\.html$/i, '.long.png'); if (!outputPath.toLowerCase().endsWith('.png')) { throw new Error('generate_long_image: output_path 必须是 .png 文件'); } const outputAbs = resolveSandboxed(outputPath); const result = await renderLongImage({ htmlPath: htmlAbs, outputPath: outputAbs }); return [ { type: 'text', text: `已生成长图 ${outputPath}(${result.bytes} 字节)`, }, ]; } case 'generate_docx': { const outputPath = String(args.output_path ?? args.path ?? '').trim(); if (!outputPath.toLowerCase().endsWith('.docx')) { throw new Error('generate_docx: output_path 必须是 .docx 文件'); } resolveSandboxed(outputPath); const title = String(args.title ?? '').trim(); if (!title) { throw new Error('generate_docx: title 不能为空'); } const sections = args.sections ?? args.payload?.sections; const result = runGenerateDocxScript({ outputPath, title, sections }); return [ { type: 'text', text: `已生成 ${outputPath}(${result.bytes} 字节)`, }, ]; } case 'private_data_info': { ensurePrivateDataDb(); const size = privateDataSize(); const quota = await getQuotaState().catch(() => null); return [ { type: 'text', text: JSON.stringify( { name: '用户私有数据空间', database: '.mindspace/private-data.sqlite', maxBytes: PRIVATE_DATA_MAX_BYTES, sizeBytes: size, quotaSyncEnabled: Boolean(getQuotaPool()), quota: quota ? { status: quota.status, quotaBytes: quota.quotaBytes, usedBytes: quota.usedBytes, reservedBytes: quota.reservedBytes, availableBytes: quota.availableBytes, } : null, rules: [ '每个用户只能使用这个唯一 SQLite 数据库', '适合问卷、表单、清单、调研数据和分析中间表', '不要存账号、计费、权限、审计、公开平台数据或跨用户数据', ], }, null, 2, ), }, ]; } case 'private_data_schema': return [{ type: 'text', text: await privateDataSchema() }]; case 'private_data_query': return [{ type: 'text', text: await queryPrivateData(args.sql) }]; case 'private_data_execute': return [{ type: 'text', text: await executePrivateData(args.sql) }]; case 'schedule_create_item': { const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; const startAt = resolveScheduleTimestamp({ epochMs: args.startAt, localString: args.startLocal, timezone, fieldName: '开始时间', }); const endAt = resolveScheduleTimestamp({ epochMs: args.endAt, localString: args.endLocal, timezone, fieldName: '结束时间', }); const dueAt = resolveScheduleTimestamp({ epochMs: args.dueAt, localString: args.dueLocal, timezone, fieldName: '截止时间', }); const item = await getScheduleService().createItem({ userId: PRIVATE_DATA_USER_ID, kind: args.kind, title: args.title, description: args.description ?? null, startAt, endAt, dueAt, allDay: Boolean(args.allDay), timezone, location: args.location ?? null, sourceChannel: 'agent', sourceMessageId: args.sourceMessageId ?? null, sourceText: args.sourceText ?? null, metadata: { source: 'schedule_assistant_skill' }, }); return [{ type: 'text', text: JSON.stringify(item, null, 2) }]; } case 'schedule_create_reminder': { const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; const remindAt = resolveScheduleTimestamp({ epochMs: args.remindAt, localString: args.remindLocal, timezone, fieldName: '提醒时间', }); if (remindAt == null) throw new Error('缺少提醒时间 remindLocal 或 remindAt'); const reminder = await getScheduleService().createReminder({ userId: PRIVATE_DATA_USER_ID, itemId: args.itemId, remindAt, offsetMinutes: args.offsetMinutes ?? null, channel: args.channel ?? 'wechat', }); return [{ type: 'text', text: JSON.stringify(reminder, null, 2) }]; } case 'schedule_list_items': { const items = await getScheduleService().listItems({ userId: PRIVATE_DATA_USER_ID, from: args.from ?? null, to: args.to ?? null, status: args.status ?? 'active', limit: args.limit ?? 50, }); return [{ type: 'text', text: JSON.stringify(items, null, 2) }]; } 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', async (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 = await 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));