2c91689692
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
642 lines
23 KiB
JavaScript
642 lines
23 KiB
JavaScript
#!/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));
|