Files
memind/mindspace-sandbox-mcp.mjs
T

954 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 {
assertPageAccessPolicyMatchesRegistry,
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 AGENT_API_BASE_URL = process.env.MINDSPACE_AGENT_API_BASE_URL?.trim();
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET?.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() };
}
async function generateMindSpaceImage({ purpose, prompt, negativePrompt, idempotencyKey }) {
if (!AGENT_API_BASE_URL || !INTERNAL_AGENT_SECRET || !PRIVATE_DATA_USER_ID) {
throw new Error('generate_image: Memind 内部生图入口未配置');
}
const endpoint = new URL('agent/mindspace_image_generate', `${AGENT_API_BASE_URL.replace(/\/+$/, '')}/`);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${INTERNAL_AGENT_SECRET}`,
'content-type': 'application/json',
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
},
body: JSON.stringify({
user_id: PRIVATE_DATA_USER_ID,
purpose,
prompt,
negative_prompt: negativePrompt,
}),
signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 900_000)),
});
let body = null;
try {
body = await response.json();
} catch {
// Safe error below intentionally excludes the response body.
}
if (!response.ok && !body?.fallback) {
throw new Error(`generate_image: Memind 返回 ${response.status}`);
}
return body?.data ?? body ?? { ok: false, fallback: true, code: 'empty_response' };
}
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_image',
description:
'通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。页面头图、卡片封面和信息流缩略图必须共享一次生成结果:优先只调用一次 hero,再从该图派生其他尺寸;禁止在 hero 成功后继续调用 card_cover/feed_cover。正文插图 inline_image 仅按需单独生成。HTML 的 img、背景图和 mindspace-cover 必须使用返回的 asset.htmlSrcworkspaceRelativePath 只用于工作区文件操作,不得直接写入 HTML。',
inputSchema: {
type: 'object',
properties: {
purpose: {
type: 'string',
enum: ['inline_image', 'hero', 'card_cover', 'feed_cover'],
description: '图片用途',
},
prompt: { type: 'string', description: '图片描述,画面内不要包含文字、水印或二维码' },
negative_prompt: { type: 'string', description: '可选负面描述' },
idempotency_key: { type: 'string', description: '可选幂等键;同一页面同一用途应复用' },
},
required: ['purpose', 'prompt'],
},
},
{
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: '页面 IDh5_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 与可访问 URLHTML 无需手写 pageId。',
inputSchema: {
type: 'object',
properties: {
relativePath: {
type: 'string',
description: '工作区相对路径,例如 public/survey.html',
},
title: { type: 'string', description: '页面标题,可选,默认从 HTML <title> 读取' },
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_image': {
const purpose = String(args.purpose ?? '').trim();
const prompt = String(args.prompt ?? '').trim();
if (!prompt) throw new Error('generate_image: prompt 不能为空');
const result = await generateMindSpaceImage({
purpose,
prompt,
negativePrompt: String(args.negative_prompt ?? '').trim(),
idempotencyKey: String(args.idempotency_key ?? '').trim(),
});
return [{ type: 'text', text: JSON.stringify(result, null, 2) }];
}
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(PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少 ownerUserId,无法保存页面数据策略');
const requestedOwnerUserId = String(args.ownerUserId ?? ownerUserId).trim();
if (requestedOwnerUserId !== ownerUserId) {
throw Object.assign(new Error('ownerUserId 必须是当前会话用户'), {
code: 'forbidden',
});
}
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 },
);
await assertPageAccessPolicyMatchesRegistry(policy, getUserDataSpaceService());
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 / workspaceUrlMindSpace 路径),不要用 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));