@mcpcn/mcp-notification
Version:
系统通知MCP服务器
385 lines (384 loc) • 22.7 kB
JavaScript
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);