UNPKG

@mcpcn/mcp-notification

Version:

系统通知MCP服务器

385 lines (384 loc) 22.7 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'; const API_BASE = process.env.REMINDER_API_BASE || 'https://www.mcpcn.cc/api'; function resolveChatSessionId(request) { const pick = (obj, keys) => { for (const k of keys) { const v = obj?.[k]; if (typeof v === 'string' && v) return v; } return undefined; }; return (pick(request?.meta, ['chatSessionId', 'chatSessionID']) ?? pick(request?.params?.meta, ['chatSessionId', 'chatSessionID']) ?? pick(request?.params?._meta, ['chatSessionId', 'chatSessionID']) ?? pick(request?._meta, ['chatSessionId', 'chatSessionID']) ?? pick(request?.params?.arguments, ['chatSessionId', 'chatSessionID'])); } async function postJson(path, body, chatSessionId) { const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; if (chatSessionId) headers['chatSessionId'] = chatSessionId; let lastError; for (let attempt = 0; attempt < 3; attempt++) { try { const resp = await fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: JSON.stringify(body), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); const msg = `HTTP error: ${resp.status} ${resp.statusText}${text ? ` | Body: ${text.slice(0, 500)}` : ''}`; if (resp.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } throw new Error(msg); } return (await resp.json()); } catch (e) { lastError = e; if (attempt < 2) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } } } throw new Error(`Execution failed: ${lastError?.message}`); } async function getJson(path, chatSessionId) { const headers = { Accept: 'application/json' }; if (chatSessionId) headers['chatSessionId'] = chatSessionId; let lastError; for (let attempt = 0; attempt < 3; attempt++) { try { const resp = await fetch(`${API_BASE}${path}`, { headers }); if (!resp.ok) { const text = await resp.text().catch(() => ''); const msg = `HTTP error: ${resp.status} ${resp.statusText}${text ? ` | Body: ${text.slice(0, 500)}` : ''}`; if (resp.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } throw new Error(msg); } return (await resp.json()); } catch (e) { lastError = e; if (attempt < 2) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } } } throw new Error(`Execution failed: ${lastError?.message}`); } class ReminderServer { constructor() { this.server = new Server({ name: 'notification-mcp', version: '1.0.0' }, { capabilities: { tools: {} } }); this.setupHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'set_reminder', description: '设置通知提醒,支持一次性、按间隔循环、每日循环。规则:当用户表述为具体某天/今天/明天/后天/某日期某时间等,请选择repeat=none;优选将具体日期时间直接计算为RFC3339并填入triggerAt,或仅提供timeOfDay让服务端自动推算到最近一次的该时间(若当天已过则推算到明天)。只有当用户明确说“每天/每日/每晚/每早/每隔X时间”时,才使用repeat=daily或repeat=interval。一次性提醒可提供triggerAt或delaySec,或提供timeOfDay用于推算,优先使用delaySec。', inputSchema: { type: 'object', properties: { content: { type: 'string', minLength: 1, description: '提醒内容,例如 “开会”。' }, repeat: { type: 'string', enum: ['none', 'interval', 'daily'], default: 'none', description: '提醒类型:none 一次性(包含“今天/明天/某天”的语义)、interval 按间隔、daily 每日(仅当用户明确要求“每天”时使用)。' }, delaySec: { type: 'number', minimum: 1, description: '一次性提醒相对延迟秒数,例如 300 表示5分钟后触发。与triggerAt二选一。' }, triggerAt: { type: 'string', description: '一次性提醒绝对时间,RFC3339 格式,例如 2025-11-21T08:00:00+08:00。' }, intervalSec: { type: 'number', minimum: 1, description: '按间隔循环的间隔秒数,例如 3600 表示每小时提醒一次。仅repeat=interval时必需。' }, timeOfDay: { type: 'string', pattern: '^\\d{1,2}:\\d{2}(:\\d{2})?$', description: '一天中的时间,格式 HH:mm 或 HH:mm:ss,例如 08:00 或 08:00:00。repeat=daily时必需;repeat=none且未提供triggerAt/delaySec时用于推算。典型用法:用户说“明天早上8点”,请选择repeat=none并设置timeOfDay="08:00",服务端会自动推算到最近的08:00。' }, tzOffsetMin: { type: 'number', description: '时区偏移分钟,例如北京为 480;不提供时默认使用本机时区。' }, chatSessionId: { type: 'string', minLength: 1, description: '设备会话标识,由宿主环境传入。' }, }, required: ['content', 'repeat'], additionalProperties: false, oneOf: [ { properties: { repeat: { const: 'none' } }, anyOf: [ { required: ['triggerAt'] }, { required: ['delaySec'] }, { required: ['timeOfDay'] }, ], }, { properties: { repeat: { const: 'interval' } }, required: ['intervalSec'], }, { properties: { repeat: { const: 'daily' } }, required: ['timeOfDay'], }, ], examples: [ { content: '开会', repeat: 'none', triggerAt: '2025-11-21T08:00:00+08:00' }, { content: '喝水', repeat: 'none', delaySec: 300 }, { content: '出发去10号线', repeat: 'none', timeOfDay: '08:00', tzOffsetMin: 480 }, { content: '站立休息', repeat: 'interval', intervalSec: 1800 }, { content: '打卡', repeat: 'daily', timeOfDay: '09:00', tzOffsetMin: 480 }, ], }, }, { name: 'list_reminders', description: '获取设备的提醒列表(仅未触发的 scheduled)。', inputSchema: { type: 'object', properties: { chatSessionId: { type: 'string', minLength: 1, description: '设备会话标识。' } }, required: [], additionalProperties: false, }, }, { name: 'cancel_reminder', description: '取消指定提醒。', inputSchema: { type: 'object', properties: { id: { type: 'string', minLength: 1, description: '提醒ID。' }, chatSessionId: { type: 'string', minLength: 1, description: '设备会话标识。' } }, required: ['id'], additionalProperties: false, }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { console.error('[调试] request.params keys = ' + JSON.stringify(Object.keys(request.params || {}))); if (!request.params.arguments || typeof request.params.arguments !== 'object') { throw new McpError(ErrorCode.InvalidParams, 'Invalid parameters'); } const name = request.params.name; const args = request.params.arguments; console.error(`收到工具调用请求: ${name}, 输入: ${JSON.stringify(args)}`); const chatSessionId = resolveChatSessionId(request); if (!chatSessionId) { console.error('通知工具未在请求中检测到 chatSessionId(meta.chatSessionId)'); const baseMsg = 'Device parsing failed: chatSessionId is required; notification tool unavailable'; const errText = name === 'set_reminder' ? `Set failed: ${baseMsg}` : name === 'list_reminders' ? `Fetch failed: ${baseMsg}` : name === 'cancel_reminder' ? `Cancel failed: ${baseMsg}` : baseMsg; return { content: [{ type: 'text', text: errText }], isError: true }; } else { console.error(`通知工具接收到 chatSessionId: ${chatSessionId}`); } if (name === 'set_reminder') { const params = { content: String(args.content || ''), repeat: String(args.repeat || ''), delaySec: args.delaySec, triggerAt: args.triggerAt, intervalSec: args.intervalSec, timeOfDay: args.timeOfDay, tzOffsetMin: args.tzOffsetMin, }; const needSingleTrigger = params.repeat === 'none'; const hasTrigger = typeof params.triggerAt === 'string' && params.triggerAt.trim().length > 0; const hasDelay = typeof params.delaySec === 'number' && Number.isFinite(params.delaySec); const sendOffsetMin = typeof params.tzOffsetMin === 'number' ? params.tzOffsetMin : -new Date().getTimezoneOffset(); params.tzOffsetMin = sendOffsetMin; if (needSingleTrigger && !hasTrigger && !hasDelay) { if (typeof params.timeOfDay === 'string' && params.timeOfDay.trim()) { const m = params.timeOfDay.trim().match(/^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*$/); if (!m) { return { content: [{ type: 'text', text: 'Set failed: timeOfDay must be HH:mm or HH:mm:ss' }], isError: true }; } const hh = parseInt(m[1], 10); const mi = parseInt(m[2], 10); const ss = m[3] ? parseInt(m[3], 10) : 0; if (hh < 0 || hh > 23 || mi < 0 || mi > 59 || ss < 0 || ss > 59) { return { content: [{ type: 'text', text: 'Set failed: timeOfDay must be HH:mm or HH:mm:ss' }], isError: true }; } const offsetMin = typeof params.tzOffsetMin === 'number' ? params.tzOffsetMin : -new Date().getTimezoneOffset(); const nowUtc = Date.now(); const zoneNowMs = nowUtc + offsetMin * 60000; const zoneNow = new Date(zoneNowMs); let y = zoneNow.getUTCFullYear(); let mth = zoneNow.getUTCMonth(); let d = zoneNow.getUTCDate(); let targetUtcMs = Date.UTC(y, mth, d, hh, mi, ss) - offsetMin * 60000; if (targetUtcMs <= nowUtc) { const tomorrowZone = new Date(zoneNowMs + 86400000); y = tomorrowZone.getUTCFullYear(); mth = tomorrowZone.getUTCMonth(); d = tomorrowZone.getUTCDate(); targetUtcMs = Date.UTC(y, mth, d, hh, mi, ss) - offsetMin * 60000; } const targetZone = new Date(targetUtcMs + offsetMin * 60000); const pad2 = (n) => String(n).padStart(2, '0'); const yStr = String(targetZone.getUTCFullYear()); const mStr = pad2(targetZone.getUTCMonth() + 1); const dStr = pad2(targetZone.getUTCDate()); const sign = offsetMin >= 0 ? '+' : '-'; const abs = Math.abs(offsetMin); const oh = pad2(Math.floor(abs / 60)); const om = pad2(abs % 60); params.triggerAt = `${yStr}-${mStr}-${dStr}T${pad2(hh)}:${pad2(mi)}:${pad2(ss)}${sign}${oh}:${om}`; console.error(`自动计算triggerAt: ${params.triggerAt}`); } else { return { content: [{ type: 'text', text: 'Set failed: provide triggerAt or delaySec, or timeOfDay to derive' }], isError: true }; } } if (needSingleTrigger && hasDelay && !hasTrigger) { const offsetMin = typeof params.tzOffsetMin === 'number' ? params.tzOffsetMin : -new Date().getTimezoneOffset(); const pad2 = (n) => String(n).padStart(2, '0'); const nowUtc = Date.now(); const targetUtcMs = nowUtc + params.delaySec * 1000; const targetZone = new Date(targetUtcMs + offsetMin * 60000); const yStr = String(targetZone.getUTCFullYear()); const mStr = pad2(targetZone.getUTCMonth() + 1); const dStr = pad2(targetZone.getUTCDate()); const hhStr = pad2(targetZone.getUTCMinutes() >= 0 ? targetZone.getUTCHours() : targetZone.getUTCHours()); const miStr = pad2(targetZone.getUTCMinutes()); const ssStr = pad2(targetZone.getUTCSeconds()); const sign = offsetMin >= 0 ? '+' : '-'; const abs = Math.abs(offsetMin); const oh = pad2(Math.floor(abs / 60)); const om = pad2(abs % 60); params.triggerAt = `${yStr}-${mStr}-${dStr}T${hhStr}:${miStr}:${ssStr}${sign}${oh}:${om}`; console.error(`delaySec计算triggerAt: ${params.triggerAt}`); } const resp = await postJson('/reminder/set', params, chatSessionId); if (resp.code !== 0) { return { content: [{ type: 'text', text: `Set failed: ${resp.msg}` }], isError: true }; } const triggerAt = resp.data?.triggerAt; const offsetMin = typeof params.tzOffsetMin === 'number' ? params.tzOffsetMin : -new Date().getTimezoneOffset(); const formatOffset = (min) => { const sign = min >= 0 ? '+' : '-'; const abs = Math.abs(min); const hh = String(Math.floor(abs / 60)).padStart(2, '0'); const mm = String(abs % 60).padStart(2, '0'); return `${sign}${hh}:${mm}`; }; const toLocalRfc3339 = (iso, offMin) => { if (!iso) return undefined; const d = new Date(iso); if (isNaN(d.getTime())) return iso; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mi = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${y}-${m}-${day}T${hh}:${mi}:${ss}${formatOffset(offMin)}`; }; const triggerAtLocal = toLocalRfc3339(triggerAt, offsetMin); return { content: [ { type: 'text', text: JSON.stringify({ id: resp.data?.id, triggerAt: resp.data?.triggerAt, triggerAtLocal, msg: resp.msg || 'Set succeeded' }, null, 2), }, ], isError: false, }; } if (name === 'list_reminders') { const resp = await getJson(`/reminder/list`, chatSessionId); if (resp.code !== 0) { return { content: [{ type: 'text', text: `Fetch failed: ${resp.msg}` }], isError: true }; } const offsetMin = typeof args?.tzOffsetMin === 'number' ? args.tzOffsetMin : -new Date().getTimezoneOffset(); const formatOffset = (min) => { const sign = min >= 0 ? '+' : '-'; const abs = Math.abs(min); const hh = String(Math.floor(abs / 60)).padStart(2, '0'); const mm = String(abs % 60).padStart(2, '0'); return `${sign}${hh}:${mm}`; }; const toLocal = (v) => { if (v === undefined || v === null) return v; let ms; if (typeof v === 'number') { ms = v > 1e12 ? v : v * 1000; } else if (typeof v === 'string') { const s = v.trim(); if (/^\d{10}$/.test(s)) ms = parseInt(s, 10) * 1000; else if (/^\d{13}$/.test(s)) ms = parseInt(s, 10); } const d = ms !== undefined ? new Date(ms) : new Date(v); if (isNaN(d.getTime())) return v; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mi = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${y}-${m}-${day}T${hh}:${mi}:${ss}${formatOffset(offsetMin)}`; }; const raw = resp.data?.list ?? []; const mapped = Array.isArray(raw) ? raw.map((it) => { if (it && typeof it === 'object') { const anyIt = it; if (anyIt.triggerAt) anyIt.triggerAtLocal = toLocal(anyIt.triggerAt); if (anyIt.nextAt) anyIt.nextAtLocal = toLocal(anyIt.nextAt); if (anyIt.scheduledAt) anyIt.scheduledAtLocal = toLocal(anyIt.scheduledAt); } return it; }) : raw; return { content: [{ type: 'text', text: JSON.stringify(mapped, null, 2) }], isError: false, }; } if (name === 'cancel_reminder') { const params = { id: String(args.id || '') }; const resp = await postJson(`/reminder/cancel`, params, chatSessionId); if (resp.code !== 0) { return { content: [{ type: 'text', text: `Cancel failed: ${resp.msg}` }], isError: true }; } return { content: [{ type: 'text', text: resp.msg || 'Cancel succeeded' }], isError: false }; } throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } catch (error) { if (error instanceof McpError) throw error; throw new McpError(ErrorCode.InternalError, `Execution failed: ${error.message}`); } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Reminder MCP server running on stdio'); } } const server = new ReminderServer(); server.run().catch(console.error);