@mcpcn/mcp-notification
Version:
系统通知MCP服务器
686 lines (685 loc) • 28.2 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';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import os from 'os';
import path from 'path';
const execAsync = promisify(exec);
// ---- Global cancellation state (cross-process) ----
const GLOBAL_STATE_DIR = path.join(os.homedir(), '.mcp', 'notification-mcp');
const GLOBAL_CANCEL_FILE = path.join(GLOBAL_STATE_DIR, 'cancel.json');
function ensureStateDir() {
try {
if (!fs.existsSync(GLOBAL_STATE_DIR)) {
fs.mkdirSync(GLOBAL_STATE_DIR, { recursive: true });
}
}
catch { }
}
function readCancelState() {
try {
const raw = fs.readFileSync(GLOBAL_CANCEL_FILE, 'utf8');
const data = JSON.parse(raw);
return {
stopAll: Boolean(data.stopAll),
canceledIds: Array.isArray(data.canceledIds) ? data.canceledIds : [],
};
}
catch {
return { stopAll: false, canceledIds: [] };
}
}
function writeCancelState(state) {
ensureStateDir();
fs.writeFileSync(GLOBAL_CANCEL_FILE, JSON.stringify(state), 'utf8');
}
function isGlobalStopAll() {
return Boolean(readCancelState().stopAll);
}
function isGloballyCanceled(id) {
const state = readCancelState();
return Boolean(state.stopAll) || (state.canceledIds || []).includes(id);
}
function setGlobalStopAll(flag) {
const state = readCancelState();
state.stopAll = flag;
writeCancelState(state);
}
function cancelTaskGlobally(id) {
const state = readCancelState();
const set = new Set(state.canceledIds || []);
set.add(id);
state.canceledIds = Array.from(set);
writeCancelState(state);
}
function clearGlobalCancel() {
writeCancelState({ stopAll: false, canceledIds: [] });
}
/**
* Escapes special characters in strings for AppleScript
*/
function escapeString(str) {
// Escape for both AppleScript and shell
return str
.replace(/'/g, "'\\''")
.replace(/"/g, '\\"');
}
// 任务池:存储所有活跃的重复提醒任务
const repeatNotificationPool = new Map();
/**
* 生成唯一任务ID
*/
function generateNotificationId() {
return 'notification_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36);
}
/**
* Validates notification parameters
*/
function validateParams(params) {
if (!params.title || typeof params.title !== 'string') {
throw new Error('Title is required and must be a string');
}
if (!params.message || typeof params.message !== 'string') {
throw new Error('Message is required and must be a string');
}
if (params.subtitle && typeof params.subtitle !== 'string') {
throw new Error('Subtitle must be a string');
}
}
/**
* Builds the AppleScript command for sending a notification
*/
function buildNotificationCommand(params) {
const { title, message, subtitle, sound = true } = params;
let script = `display notification "${escapeString(message)}" with title "${escapeString(title)}"`;
if (subtitle) {
script += ` subtitle "${escapeString(subtitle)}"`;
}
if (sound) {
script += ` sound name "default"`;
}
return `osascript -e '${script}'`;
}
// 检测操作系统
function getOS() {
const currentPlatform = process.platform;
if (currentPlatform === 'win32')
return 'windows';
if (currentPlatform === 'darwin')
return 'macos';
return 'linux';
}
// Windows 通知命令构建
function buildWindowsNotificationCommand(params) {
const { title, message, sound = true } = params;
// 使用 PowerShell 的 BalloonTip
let script = `
Add-Type -AssemblyName System.Windows.Forms;
$notification = New-Object System.Windows.Forms.NotifyIcon;
$notification.Icon = [System.Drawing.SystemIcons]::Information;
$notification.BalloonTipTitle = "${escapeString(title)}";
$notification.BalloonTipText = "${escapeString(message)}";
$notification.Visible = $true;
$notification.ShowBalloonTip(5000);
`;
if (sound) {
script += `
[System.Media.SystemSounds]::Asterisk.Play();
`;
}
script += `
Start-Sleep -Seconds 1;
$notification.Dispose();
`;
return `powershell -Command "${script}"`;
}
// Linux 通知命令构建
function buildLinuxNotificationCommand(params) {
const { title, message, subtitle, sound = true } = params;
let command = `notify-send "${escapeString(title)}" "${escapeString(message)}"`;
if (subtitle) {
// 将 subtitle 添加到消息中,因为 notify-send 不直接支持副标题
command = `notify-send "${escapeString(title)}" "${escapeString(subtitle)}\n${escapeString(message)}"`;
}
// 添加声音支持
if (sound) {
command += ` --hint=string:sound-name:message-new-instant`;
}
return command;
}
// 直接执行:macOS 通知
async function executeMacNotification(params) {
const command = buildNotificationCommand(params);
await execAsync(command);
}
// 直接执行:Windows 通知
async function executeWindowsNotification(params) {
const { title, message, sound = true } = params;
let script = `
Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
$notification = New-Object System.Windows.Forms.NotifyIcon;
$notification.Icon = [System.Drawing.SystemIcons]::Information;
$notification.BalloonTipTitle = "${escapeString(title)}";
$notification.BalloonTipText = "${escapeString(message)}";
$notification.Visible = $true;
$notification.ShowBalloonTip(5000);
`;
if (sound) {
script += `
[System.Media.SystemSounds]::Asterisk.Play();
`;
}
script += `
$sw = [Diagnostics.Stopwatch]::StartNew();
while ($sw.ElapsedMilliseconds -lt 6000) {
[System.Windows.Forms.Application]::DoEvents();
Start-Sleep -Milliseconds 100;
}
$notification.Dispose();
`;
const encoded = Buffer.from(script, 'utf16le').toString('base64');
const command = `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
await execAsync(command);
}
// 直接执行:Linux 通知
async function executeLinuxNotification(params) {
const command = buildLinuxNotificationCommand(params);
await execAsync(command);
}
/**
* 解析时间字符串为毫秒数
*/
function parseTimeDelay(delay) {
if (typeof delay === 'number') {
return delay;
}
const timeString = delay.toLowerCase().trim();
const match = timeString.match(/^(\d+(?:\.\d+)?)\s*([smh]?)$/);
if (!match) {
throw new Error('Invalid time format. Use numbers (milliseconds) or strings like "10s", "1m", "1h"');
}
const value = parseFloat(match[1]);
const unit = match[2] || 'ms'; // 默认单位为毫秒
switch (unit) {
case 's': return value * 1000; // 秒
case 'm': return value * 60 * 1000; // 分钟
case 'h': return value * 60 * 60 * 1000; // 小时
default: return value; // 毫秒
}
}
/**
* Sends a notification using the appropriate platform command
*/
async function sendNotification(params) {
// 如果有 repeat 参数,设置重复提醒
if (params.repeat !== undefined) {
const repeatMs = parseTimeDelay(params.repeat);
if (repeatMs <= 0) {
throw new Error('Repeat interval must be a positive number');
}
const notificationId = generateNotificationId();
const { repeat, repeatCount, ...notificationParams } = params;
const maxCount = repeatCount || Infinity;
// 创建重复发送的函数
const scheduleNextNotification = (currentCount) => {
if (currentCount >= maxCount) {
// 任务完成,从任务池中移除
repeatNotificationPool.delete(notificationId);
return;
}
const timeoutId = setTimeout(async () => {
try {
// 全局取消检查(支持重启后仍能停止)
if (isGloballyCanceled(notificationId)) {
// 从任务池中移除并停止
repeatNotificationPool.delete(notificationId);
return;
}
// 检查任务是否还在任务池中(可能已被取消)
const notification = repeatNotificationPool.get(notificationId);
if (!notification)
return;
await sendNotification(notificationParams);
// 更新任务信息
notification.currentCount++;
// 调度下一次通知(再次检查 stopAll)
if (!isGlobalStopAll()) {
scheduleNextNotification(currentCount + 1);
}
}
catch (error) {
console.error('Repeated notification failed:', error);
// 即使失败也继续下一次
if (!isGlobalStopAll()) {
scheduleNextNotification(currentCount + 1);
}
}
}, repeatMs);
// 更新任务池中的任务信息
const notification = repeatNotificationPool.get(notificationId);
if (notification) {
// 清除旧的timeout
if (notification.timeoutId) {
clearTimeout(notification.timeoutId);
}
notification.timeoutId = timeoutId;
}
};
// 创建任务并加入任务池
const notification = {
id: notificationId,
params,
timeoutId: null, // 稍后设置
currentCount: 0,
maxCount,
startTime: Date.now()
};
repeatNotificationPool.set(notificationId, notification);
// 如果有初始延迟,先等待延迟再开始重复
if (params.delay !== undefined) {
const delayMs = parseTimeDelay(params.delay);
notification.timeoutId = setTimeout(() => {
// 发送第一次通知并开始重复
if (isGloballyCanceled(notificationId)) {
repeatNotificationPool.delete(notificationId);
return;
}
sendNotification(notificationParams).then(() => {
notification.currentCount = 1;
scheduleNextNotification(1);
}).catch(error => {
console.error('Initial repeated notification failed:', error);
scheduleNextNotification(1);
});
}, delayMs);
}
else {
// 没有初始延迟,立即开始第一次通知
try {
if (isGloballyCanceled(notificationId)) {
repeatNotificationPool.delete(notificationId);
return { message: 'Notification canceled before start' };
}
await sendNotification(notificationParams);
notification.currentCount = 1;
scheduleNextNotification(1);
}
catch (error) {
console.error('Initial repeated notification failed:', error);
scheduleNextNotification(1);
}
}
return {
notificationId,
message: `Repeat notification notification created with ID: ${notificationId}`
};
}
// 如果有 delay 参数但没有 repeat,使用 setTimeout 延迟发送
if (params.delay !== undefined) {
const delayMs = parseTimeDelay(params.delay);
if (delayMs <= 0) {
throw new Error('Delay must be a positive number');
}
try {
// 生成任务ID,并加入任务池,便于后续管理/取消
const notificationId = generateNotificationId();
const record = {
id: notificationId,
params,
timeoutId: null,
currentCount: 0,
maxCount: 1,
startTime: Date.now(),
};
repeatNotificationPool.set(notificationId, record);
const timeoutId = setTimeout(async () => {
try {
if (isGloballyCanceled(notificationId)) {
repeatNotificationPool.delete(notificationId);
return;
}
// 如果任务已被取消并从池中移除,则不再执行
const exists = repeatNotificationPool.get(notificationId);
if (!exists)
return;
// 创建不包含 delay 的参数对象,避免无限递归
const { delay, ...notificationParams } = params;
await sendNotification(notificationParams);
}
catch (error) {
console.error('Delayed notification failed:', error);
}
finally {
// 单次延迟任务执行完成后从任务池移除
repeatNotificationPool.delete(notificationId);
}
}, delayMs);
// 回填 timeoutId
record.timeoutId = timeoutId;
// 返回任务ID,便于调用方管理
return { notificationId, message: 'Delayed notification scheduled successfully' };
}
catch (error) {
throw new Error('Failed to schedule delayed notification');
}
}
// 立即发送通知的逻辑
try {
validateParams(params);
const os = getOS();
switch (os) {
case 'macos':
await executeMacNotification(params);
break;
case 'windows':
await executeWindowsNotification(params);
break;
case 'linux':
await executeLinuxNotification(params);
break;
default:
throw new Error(`Unsupported platform: ${os}`);
}
return { message: 'Notification sent successfully' };
}
catch (error) {
if (error instanceof Error) {
throw error;
}
// Handle different types of system errors
const err = error;
if (err.message.includes('execution error')) {
throw new Error('Failed to execute notification command');
}
else if (err.message.includes('permission')) {
throw new Error('Permission denied when trying to send notification');
}
else {
throw new Error(`Unexpected error: ${err.message}`);
}
}
}
/**
* 停止指定的重复提醒任务
*/
function stopRepeatNotification(notificationId) {
const notification = repeatNotificationPool.get(notificationId);
if (!notification) {
return false;
}
// 清除定时器
if (notification.timeoutId) {
clearTimeout(notification.timeoutId);
}
// 从任务池中移除
repeatNotificationPool.delete(notificationId);
return true;
}
/**
* 停止所有重复提醒任务
*/
function stopAllRepeatNotifications() {
const count = repeatNotificationPool.size;
// 清除所有定时器
for (const notification of repeatNotificationPool.values()) {
if (notification.timeoutId) {
clearTimeout(notification.timeoutId);
}
}
// 清空任务池
repeatNotificationPool.clear();
return count;
}
/**
* 获取所有活跃的重复提醒任务信息
*/
function getActiveRepeatNotifications() {
return Array.from(repeatNotificationPool.values()).map(notification => ({
...notification,
// 不返回timeoutId,避免序列化问题
timeoutId: null
}));
}
/**
* 获取指定任务的信息
*/
function getRepeatNotificationInfo(notificationId) {
const notification = repeatNotificationPool.get(notificationId);
if (!notification) {
return null;
}
return {
...notification,
// 不返回timeoutId,避免序列化问题
timeoutId: null
};
}
class NotificationServer {
constructor() {
this.server = new Server({
name: 'notification-mcp',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
setupToolHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'send_notification',
description: '发送系统通知或提醒',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: '通知或提醒的标题',
},
message: {
type: 'string',
description: '通知或提醒的内容',
},
subtitle: {
type: 'string',
description: '可选的副标题',
},
sound: {
type: 'boolean',
description: '是否播放默认提示音',
default: true,
},
delay: {
oneOf: [
{ type: 'number' },
{ type: 'string' }
],
description: '延迟发送通知或提醒(毫秒或时间字符串如"10s", "1m", "1h")',
},
repeat: {
oneOf: [
{ type: 'number' },
{ type: 'string' }
],
description: '重复通知或提醒的间隔(毫秒或时间字符串如"10s", "1m", "1h")',
},
repeatCount: {
type: 'number',
description: '重复次数(可选,如果设置了repeat但未设置此项则无限重复)',
minimum: 1,
},
},
required: ['title', 'message'],
additionalProperties: false,
},
},
{
name: 'notification_task_management',
description: '管理计划的通知或提醒任务',
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['stop_repeat_task', 'stop_all_repeat_tasks', 'get_active_repeat_tasks', 'get_repeat_task_info', 'stop_all_repeat_tasks_globally', 'stop_repeat_task_globally', 'clear_global_state'],
description: 'stop_repeat_task: 停止指定的重复通知或提醒任务. stop_all_repeat_tasks: 停止所有重复通知或提醒任务. get_active_repeat_tasks: 获取所有活跃的重复通知或提醒任务. get_repeat_task_info: 获取指定重复通知或提醒任务的信息.'
},
taskId: {
type: 'string',
description: '要管理的任务ID'
}
},
required: ['action'],
additionalProperties: false
}
},
],
}));
// Handle tool execution
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
if (!request.params.arguments || typeof request.params.arguments !== 'object') {
throw new McpError(ErrorCode.InvalidParams, 'Invalid parameters');
}
switch (request.params.name) {
case 'send_notification': {
const { title, message, subtitle, sound, delay, repeat, repeatCount } = request.params.arguments;
if (typeof title !== 'string' || typeof message !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'Title and message must be strings');
}
const params = {
title,
message,
subtitle: typeof subtitle === 'string' ? subtitle : undefined,
sound: typeof sound === 'boolean' ? sound : undefined,
delay: (typeof delay === 'number' || typeof delay === 'string') ? delay : undefined,
repeat: (typeof repeat === 'number' || typeof repeat === 'string') ? repeat : undefined,
repeatCount: typeof repeatCount === 'number' ? repeatCount : undefined
};
const result = await sendNotification(params);
return {
content: [
{
type: 'text',
text: result.notificationId ?
`${result.message}. Task ID: ${result.notificationId}` :
result.message,
},
],
isError: false,
};
}
case 'notification_task_management': {
const { action, taskId } = request.params.arguments;
switch (action) {
case 'stop_repeat_task': {
const success = stopRepeatNotification(taskId);
return {
content: [
{
type: 'text',
text: success ? `任务 ${taskId} 已成功停止` : `任务 ${taskId} 未找到`,
},
],
isError: false,
};
}
case 'stop_all_repeat_tasks': {
const count = stopAllRepeatNotifications();
return {
content: [
{
type: 'text',
text: `已停止 ${count} 个重复任务`,
},
],
isError: false,
};
}
case 'stop_repeat_task_globally': {
if (typeof taskId !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'taskId is required for stop_repeat_task_globally');
}
cancelTaskGlobally(taskId);
// 也尝试本进程内停止
const success = stopRepeatNotification(taskId);
return {
content: [{ type: 'text', text: `已全局标记停止任务 ${taskId}` + (success ? '(并停止本进程任务)' : '') }],
isError: false,
};
}
case 'stop_all_repeat_tasks_globally': {
setGlobalStopAll(true);
const count = stopAllRepeatNotifications();
return {
content: [{ type: 'text', text: `已全局标记停止所有重复任务,并停止本进程内 ${count} 个任务` }],
isError: false,
};
}
case 'clear_global_state': {
clearGlobalCancel();
return { content: [{ type: 'text', text: '已清除全局停止状态' }], isError: false };
}
case 'get_active_repeat_tasks': {
const tasks = getActiveRepeatNotifications();
return {
content: [
{
type: 'text',
text: JSON.stringify(tasks, null, 2),
},
],
isError: false,
};
}
case 'get_repeat_task_info': {
const info = getRepeatNotificationInfo(taskId);
return {
content: [
{
type: 'text',
text: info ? JSON.stringify(info, null, 2) : `任务 ${taskId} 未找到`,
},
],
isError: false,
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown task management action: ${action}`);
}
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Notification MCP server running on stdio');
}
}
const server = new NotificationServer();
server.run().catch(console.error);