#!/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 { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
import { renderLongImage } from './mindspace-long-image.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
import { closePolicyDataset, normalizePageAccessPolicy } from './page-access-policy.mjs';
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from './mindspace-runtime-config.mjs';
import { assertNoProhibitedBrowserStorage } from './mindspace-browser-storage-policy.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 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:
'查看当前用户“用户私有数据空间”的状态。每个用户只有一个由平台分配和隔离的数据空间,适合保存问卷、表单、清单、调研数据和分析中间表;不要创建额外数据库文件。',
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'],
},
},
{
name: 'private_data_register_dataset',
description:
'注册或更新页面可访问的 dataset。Agent 建表后应注册 dataset,供 HTML 页面通过 Page Data API 受控读写。',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'dataset 名称,例如 registrations' },
table: { type: 'string', description: '映射的 SQLite 表名' },
description: { type: 'string', description: 'dataset 说明,可选' },
actions: {
type: 'array',
items: { type: 'string' },
description: '允许动作,例如 read、insert、update、soft_delete',
},
columns: {
type: 'object',
description: '各动作允许的字段白名单,例如 { read: ["id","name"], insert: ["name"] }',
},
limits: {
type: 'object',
description: '限制,例如 { maxRowsPerRead: 100, maxInsertBytes: 8192 }',
},
},
required: ['name', 'table', 'actions', 'columns'],
},
},
{
name: 'private_data_set_page_policy',
description:
'为已发布页面配置 Page Data 访问策略。策略保存在工作区 .mindspace/page-data-policies/ 下,供公开页面通过 Page Data API 受控读写。',
inputSchema: {
type: 'object',
properties: {
pageId: { type: 'string', description: '页面 ID(h5_page_records.id)' },
ownerUserId: { type: 'string', description: '页面 owner 用户 ID,可选,默认当前会话用户' },
accessMode: {
type: 'string',
description: '访问模式:public、password、login_required',
},
datasets: {
type: 'object',
description:
'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] }, rowPolicy: { scope: "own_rows" } } }',
},
defaultVisitorRole: {
type: 'string',
description: 'login_required 模式下未列名访问者的默认角色:deny、viewer、editor',
},
visitors: {
type: 'object',
description: '访问者角色映射,例如 { "user-2": "editor", "user-3": "viewer" }',
},
roles: {
type: 'object',
description: '可选角色权限覆盖,例如 { viewer: { read: true, insert: false } }',
},
},
required: ['pageId', 'accessMode', 'datasets'],
},
},
{
name: 'private_data_close_page_dataset',
description:
'关闭已发布页面的某个 dataset 公开访问能力。写入策略文件并将 read/insert/update/delete 全部关闭。',
inputSchema: {
type: 'object',
properties: {
pageId: { type: 'string', description: '页面 ID' },
dataset: { type: 'string', description: '要关闭的 dataset 名称' },
ownerUserId: { type: 'string', description: '页面 owner 用户 ID,可选,默认当前会话用户' },
},
required: ['pageId', 'dataset'],
},
},
{
name: 'private_data_bind_workspace_page',
description:
'将工作区 public/*.html 绑定为 MindSpace 页面记录、发布并配置 Page Data 策略。返回 pageId 与可访问 URL,HTML 无需手写 pageId。',
inputSchema: {
type: 'object',
properties: {
relativePath: {
type: 'string',
description: '工作区相对路径,例如 public/survey.html',
},
title: { type: 'string', description: '页面标题,可选,默认从 HTML
读取' },
accessMode: {
type: 'string',
description:
'发布访问模式:public、password、login_required;纯静态页默认 public,含 Page Data datasets 时默认 password',
},
password: {
type: 'string',
description:
'口令访问密码,accessMode=password 时可选;未提供则默认 88888888(至少 8 位)',
},
urlSlug: { type: 'string', description: '发布 URL slug,可选' },
datasets: {
type: 'object',
description:
'Page Data dataset 授权,例如 { survey_responses: { insert: true, read: true, columns: { insert: ["name"], read: ["id","name"] } } }',
},
},
required: ['relativePath'],
},
},
];
let quotaPool = null;
let scheduleService = null;
let userDataSpaceService = 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: '原始用户文本,可选' },
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳,可选(优先使用 remindLocal)' },
remindLocal: { type: 'string', description: '本地提醒时间 YYYY-MM-DD HH:mm;需要到点提醒时推荐与 startLocal 同传' },
offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' },
channel: { type: 'string', description: '提醒通道,默认 wechat,可选' },
noReminder: { type: 'boolean', description: '明确只记事项、不创建到点提醒时传 true' },
},
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 getUserDataSpaceService() {
if (!userDataSpaceService) {
userDataSpaceService = createUserDataSpaceService({
workspaceRoot: SANDBOX,
userId: PRIVATE_DATA_USER_ID || null,
query: getQuotaPool(),
sqliteBin: SQLITE_BIN,
maxBytes: PRIVATE_DATA_MAX_BYTES,
queryTimeoutMs: PRIVATE_DATA_QUERY_TIMEOUT_MS,
maxRows: PRIVATE_DATA_MAX_ROWS,
});
}
return userDataSpaceService;
}
function resolveH5RootFromSandbox() {
const parent = path.basename(path.dirname(SANDBOX));
if (parent === 'MindSpace') return path.dirname(path.dirname(SANDBOX));
return SANDBOX_MODULE_DIR;
}
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);
assertNoProhibitedBrowserStorage(args.content ?? '', { relativePath: 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 ?? '');
assertNoProhibitedBrowserStorage(updated, { relativePath: args.path });
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': {
const info = await getUserDataSpaceService().getInfo();
return [
{
type: 'text',
text: JSON.stringify(
{
...info,
rules: [
info.backend === 'postgres'
? '每个用户只能使用新 PG 中分配给自己的独立 schema'
: '每个用户只能使用这个唯一 SQLite 数据库',
'适合问卷、表单、清单、调研数据和分析中间表',
'不要存账号、计费、权限、审计、公开平台数据或跨用户数据',
'HTML 页面通过 Page Data API 访问 dataset,不要直接暴露 SQL',
],
},
null,
2,
),
},
];
}
case 'private_data_schema': {
const schema = await getUserDataSpaceService().getSchema();
return [{ type: 'text', text: JSON.stringify(schema, null, 2) }];
}
case 'private_data_query': {
const rows = await getUserDataSpaceService().querySql(args.sql);
return [{ type: 'text', text: JSON.stringify(rows, null, 2) }];
}
case 'private_data_execute': {
const result = await getUserDataSpaceService().executeSql(args.sql);
return [
{
type: 'text',
text: result.backend === 'postgres'
? `已在用户专属 PG schema 中执行。命令 ${result.command ?? 'OK'},影响 ${result.affectedRows ?? 0} 行`
: `已执行。当前数据空间大小 ${result.sizeBytes} 字节${result.quotaSynced ? `,已同步空间占用 ${result.deltaBytes} 字节` : ''}`,
},
];
}
case 'private_data_register_dataset': {
const dataset = await getUserDataSpaceService().upsertDataset({
name: args.name,
table: args.table,
description: args.description,
actions: args.actions,
columns: args.columns,
limits: args.limits,
});
return [{ type: 'text', text: JSON.stringify(dataset, null, 2) }];
}
case 'private_data_set_page_policy': {
const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少 ownerUserId,无法保存页面数据策略');
const policy = normalizePageAccessPolicy(
{
pageId: args.pageId,
ownerUserId,
accessMode: args.accessMode,
datasets: args.datasets,
defaultVisitorRole: args.defaultVisitorRole,
visitors: args.visitors,
roles: args.roles,
},
{ fallbackPageId: args.pageId, fallbackOwnerUserId: ownerUserId },
);
const saved = writePageAccessPolicy(SANDBOX, policy);
if (isQuotaSyncConfigured()) {
await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null);
}
return [{ type: 'text', text: JSON.stringify(saved, null, 2) }];
}
case 'private_data_close_page_dataset': {
const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少 ownerUserId,无法关闭 dataset');
const policy = readPageAccessPolicy(SANDBOX, args.pageId);
if (!policy) throw new Error('页面数据策略不存在');
if (policy.ownerUserId !== ownerUserId) throw new Error('无权关闭该页面 dataset');
const closed = closePolicyDataset(policy, args.dataset);
const saved = writePageAccessPolicy(SANDBOX, closed);
if (isQuotaSyncConfigured()) {
await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null);
}
return [{ type: 'text', text: JSON.stringify(saved.datasets[args.dataset], null, 2) }];
}
case 'private_data_bind_workspace_page': {
const ownerUserId = String(PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少用户上下文,无法绑定页面');
const pool = getQuotaPool();
if (!pool) throw new Error('当前环境未配置数据库,无法绑定页面');
const hasDatasets =
args.datasets != null &&
typeof args.datasets === 'object' &&
Object.keys(args.datasets).length > 0;
const accessMode =
String(args.accessMode ?? (hasDatasets ? 'password' : 'public')).trim() ||
(hasDatasets ? 'password' : 'public');
const h5Root = resolveH5RootFromSandbox();
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: ownerUserId,
workspaceRoot: SANDBOX,
relativePath: args.relativePath,
title: args.title,
accessMode,
password: args.password,
urlSlug: args.urlSlug,
pageDataPolicy: hasDatasets
? {
ownerUserId,
accessMode,
datasets: args.datasets,
}
: null,
});
return [
{
type: 'text',
text: JSON.stringify(
{
...result,
userDeliveryNote:
'向用户交付链接时优先使用 deliveryUrl / workspaceUrl(MindSpace 路径),不要用 publicationUrl',
},
null,
2,
),
},
];
}
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' },
});
let remindAt = resolveScheduleTimestamp({
epochMs: args.remindAt,
localString: args.remindLocal,
timezone,
fieldName: '提醒时间',
});
if (
remindAt == null
&& shouldAutoCreateReminderAtStart({
title: args.title,
description: args.description,
startAt,
noReminder: Boolean(args.noReminder),
})
) {
remindAt = startAt;
}
if (remindAt != null) {
const reminder = await getScheduleService().createReminder({
userId: PRIVATE_DATA_USER_ID,
itemId: item.id,
remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? 'wechat',
});
return [{ type: 'text', text: JSON.stringify({ item, reminder }, null, 2) }];
}
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));