Files
memind/mindspace-sandbox-mcp.mjs
T
john 2c91689692 feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

642 lines
23 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 { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.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 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;
}
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: '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 '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));