2025-06-24 更新
VSCode插件版本已写完,有兴趣的可以看一下:
【VSCode插件】【p2p网络】为了硬写一个和MCP交互的日程表插件(Cursor/Trae),我学习了去中心化的libp2p - 掘金
代码仓库
前文
继前文之后( 写个MCP服务让Cursor帮我去找SVG图标(iconfont)【入门】本文基于ModelContextProtoco - 掘金), 我一直在想MCP还有什么好玩的写一下。想起经常用手机的小爱同学同学帮我设闹钟,我在想我能不能写个MCP让Cursor/Trae也变身一下小爱同学每天提醒我点外卖(有时候打着代码就忘了,然后又要等)。
先看一下cursor原生是否支持提醒功能 :
作为编程助手的Cursor没有这些冗余功能是正常的。那我可就要多余写一下了。
一开始是想写一个 VS Code 插件(提醒功能) + MCP Service(提供交互功能)。但是对插件开发并不熟悉,还是先写一个electron版本的试试水。
最终实现效果
Cursor 和 MCP Server交互
Trae 和 MCP Server交互
Electron 系统托盘图标
Electron 主界面
Electron 到点提醒
功能实现如下
Electron 功能
- 提供系统级的提醒功能。
- 支持提醒普通日程(单次)。
- 重复任务(每周/每日/每月)的提醒。
- 使用了
vue3提供了按日或周为维度查看日程/提醒的界面。 - 启动了一个
express服务提供给MCP Service进行交互。
MCP Server
主要提供了以下几个工具:
get-current-date: 获取当前日期,进行日程操作时先执行这个更新日期。add-schedule: 添加日程或提醒,如果用户没有指定结束时间: end,则默认结束时间为开始时间: start或提醒时间: reminder加一小时get-schedules: 获取日程delete-schedule: 删除日程
Electron 核心实现
日程/提醒的数据结构
Electron 部分最核心的实现就是日程中需要加入到重复日程, 如每天,每周、每月或每年固定日期的重复时间。以下是这个日程的数据结构(也是cursor帮我设计了一下)。我一开始 想这个结构在每年的时候会有问题,如果repeatDays代表的是每年的第几天,考虑到二月的情况,可能会不准确,但是如果以每年都是366天为基准之后,好像也没问题。以下是具体结构:
// 日程列表数组
scheduleList: {
type: 'array',
items: {
type: 'object',
required: ['id', 'title', 'start', 'end'], // 必填字段
properties: {
id: {
type: 'string' // 日程唯一标识
},
title: {
type: 'string',
default: '' // 日程标题
},
start: {
type: 'string',
default: '' // 开始时间
},
end: {
type: 'string',
default: '' // 结束时间
},
type: {
type: 'string',
enum: ['important', 'normal', 'minor', 'meeting', 'work', 'study', 'exercise', 'entertainment', 'shopping'],
default: 'normal' // 日程类型
},
reminder: {
type: 'string',
default: '' // 提醒时间
},
description: {
type: 'string',
default: '' // 日程描述
},
allDay: {
type: 'boolean',
default: false // 是否全天事件
},
repeatType: {
type: 'string',
enum: ['none', 'daily', 'weekly', 'monthly', 'custom'],
default: 'none' // 重复类型
},
repeatInterval: {
type: 'number',
default: 1 // 重复间隔
},
repeatDays: {
type: 'array',
items: { type: 'number' },
default: [] // 重复的日期
},
repeatEnd: {
type: 'string',
default: '' // 重复结束时间
}
}
},
default: [] // 默认空数组
},
有了存储结构后,需要将存储的数据转化为定时任务,这里我们用到了一个库node-schedule进行定时提醒。
expandRecurringSchedules:展开循环日程,将循环日程转换为具体的时间实例
在上述存储结构中,存在着普通的单次日程/提醒和循环重复的日程/提醒,我们需要一个 方法去展开循环日程/提醒转化为当天需要进行提醒的任务,再使用node-schedule进行定时提醒。也需要一个方法,当打开日程表界面的时候,需要把循环/重复日程展开成具体日期内的单个任务。以下是具体实现:
import dayjs from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
dayjs.extend(isSameOrBefore)
/**
* 展开循环日程,将循环日程转换为具体的时间实例
* @param {Array} schedules - 原始日程列表
* @param {string} fromDate - 开始日期
* @param {string} toDate - 结束日期
* @returns {Array} 展开后的日程列表,包含所有循环日程的具体实例
*/
export function expandRecurringSchedules(schedules, fromDate, toDate) {
const result = [];
// 将日期字符串转换为 dayjs 对象以便于处理
const dFrom = dayjs(fromDate)
const dTo = dayjs(toDate)
schedules.forEach(schedule => {
// 处理非循环日程
if (!schedule.repeatType || schedule.repeatType === 'none') {
// 非循环日程直接加入结果列表
result.push(schedule);
} else {
// 处理循环日程
// 获取原始日程的时间信息
const originStart = dayjs(schedule.start)
const originEnd = dayjs(schedule.end)
const originReminder = schedule.reminder ? dayjs(schedule.reminder) : null
// 获取循环结束时间,如果没有设置则使用查询结束时间
const repeatEnd = schedule.repeatEnd ? dayjs(schedule.repeatEnd) : dTo
// 获取循环间隔,默认为1
const interval = schedule.repeatInterval || 1
// 确定循环的起始点
// 如果查询开始时间晚于原始开始时间,则从查询开始时间开始
let current = dFrom.isAfter(originStart) ? dFrom : originStart
// 只保留日期部分,忽略具体时间
current = current.startOf('day')
// 循环生成日程实例,直到超过结束时间或循环结束时间
while (current.isSameOrBefore(dTo) && current.isSameOrBefore(repeatEnd)) {
let shouldAdd = false;
// 根据循环类型判断当前日期是否需要添加日程
if (schedule.repeatType === 'daily') {
// 每天循环
shouldAdd = true;
} else if (schedule.repeatType === 'weekly') {
// 每周循环,检查是否在指定的星期几
// repeatDays: [0,1,2,3,4,5,6] 0=周日
if (Array.isArray(schedule.repeatDays) && schedule.repeatDays.includes(current.day())) {
shouldAdd = true;
}
} else if (schedule.repeatType === 'monthly') {
// 每月循环,检查是否在指定的日期
if (current.date() === originStart.date()) {
shouldAdd = true;
}
}
if (shouldAdd) {
// 创建新的日程实例
// 使用当前日期 + 原始开始时间的时分秒
const instanceStart = current
.hour(originStart.hour())
.minute(originStart.minute())
.second(originStart.second())
.millisecond(originStart.millisecond())
// 计算持续时间并设置结束时间
const duration = originEnd.diff(originStart)
const instanceEnd = instanceStart.add(duration, 'millisecond')
// 计算提醒时间
let instanceReminder = ''
if (originReminder) {
const reminderOffset = originReminder.diff(originStart)
instanceReminder = instanceStart.add(reminderOffset, 'millisecond').toISOString()
}
// 将新生成的日程实例添加到结果列表
result.push({
...schedule,
start: instanceStart.toISOString(),
end: instanceEnd.toISOString(),
reminder: instanceReminder,
// originalId: schedule.id
})
}
// 移动到下一个可能的日期
if (schedule.repeatType === 'daily') {
current = current.add(interval, 'day')
} else if (schedule.repeatType === 'weekly') {
current = current.add(1, 'day')
} else if (schedule.repeatType === 'monthly') {
current = current.add(interval, 'month')
} else {
break;
}
}
}
});
return result;
}
Express
考虑MCP Server 怎么和 Electron通讯,考虑过使用命令行执行命令的方式,不过实在是不熟悉,还是作为一个死web仔,还是用熟悉的http作为通讯的手段吧。在Electron中使用Express启动一个简易的Web服务, 再在MCP Server中,通过发起http请求的形式与Electron进行通讯。 以下是Express 代码部分,主要是简单的增删改查,实际以代码仓库为标准。
// 创建 Express 服务
const expressApp = express()
const PORT = 3001
// 中间件
expressApp.use(cors())
expressApp.use(express.json())
// 获取所有日程
expressApp.get('/api/schedules', (req, res) => {
const schedules = store.get('scheduleList', [])
res.json(schedules)
})
// 根据时间范围获取日程
expressApp.get('/api/schedules/range', (req, res) => {
try {
const { start, end } = req.query;
if (!start || !end) {
return res.status(400).json({
error: '请提供开始时间(start)和结束时间(end)参数'
});
}
// 验证日期格式 YYYY-MM-DD HH:mm:ss
const dateFormatRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
if (!dateFormatRegex.test(start) || !dateFormatRegex.test(end)) {
return res.status(400).json({
error: '日期格式无效,请使用 YYYY-MM-DD HH:mm:ss 格式'
});
}
const startDate = new Date(start.replace(' ', 'T'));
const endDate = new Date(end.replace(' ', 'T'));
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).json({
error: '无效的日期值'
});
}
const schedules = store.get('scheduleList', []);
const rangeSchedules = expandRecurringSchedules(schedules, startDate, endDate)
.filter(schedule => {
const scheduleDate = new Date(schedule.start);
return scheduleDate >= startDate && scheduleDate <= endDate;
});
// 格式化返回的日期为 YYYY-MM-DD HH:mm:ss
const formatDate = (date) => {
const pad = (num) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
res.json({
schedules: rangeSchedules.map(schedule => ({
...schedule,
start: formatDate(new Date(schedule.start)),
end: formatDate(new Date(schedule.end)),
reminder: schedule.reminder ? formatDate(new Date(schedule.reminder)) : null
})),
rangeInfo: {
start: formatDate(startDate),
end: formatDate(endDate),
count: rangeSchedules.length
}
});
} catch (error) {
res.status(500).json({
error: '服务器内部错误',
message: error.message
});
}
});
// 检查单个日程是否需要提醒
const checkSingleReminder = (scheduleItem) => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const reminderTime = new Date(scheduleItem.reminder)
if (reminderTime >= today && reminderTime < tomorrow && !scheduleItem.hasNotified) {
const job = schedule.scheduleJob(reminderTime, () => {
showReminderNotification(scheduleItem)
// 更新存储中的日程状态
const schedules = store.get('scheduleList', [])
const updatedSchedules = schedules.map(s =>
s.id === scheduleItem.id ? { ...s, hasNotified: true } : s
)
store.set('scheduleList', updatedSchedules)
// 取消任务
job.cancel()
})
return job
}
return null
}
// 创建新日程
expressApp.post('/api/schedules', (req, res) => {
const schedules = store.get('scheduleList', [])
const newSchedule = {
...req.body,
id: Date.now().toString(), // 使用时间戳作为临时ID
hasNotified: false
}
schedules.push(newSchedule)
store.set('scheduleList', schedules)
// 判断是否为循环任务
if (newSchedule.repeatType && newSchedule.repeatType !== 'none') {
// 只为今天范围内的实例创建提醒
const today = dayjs().startOf('day')
const tomorrow = today.add(1, 'day')
const todayInstances = expandRecurringSchedules([newSchedule], today, tomorrow)
todayInstances.forEach(instance => {
if (instance.reminder) {
checkSingleReminder(instance)
}
})
} else if (newSchedule.reminder) {
// 非循环任务,直接创建提醒
checkSingleReminder(newSchedule)
}
if (scheduleWindow && scheduleWindow.webContents) {
scheduleWindow.webContents.send('schedule-updated')
}
res.status(201).json(newSchedule)
})
// 更新日程
expressApp.put('/api/schedules/:id', (req, res) => {
const schedules = store.get('scheduleList', [])
const index = schedules.findIndex(s => s.id === req.params.id)
if (index === -1) {
return res.status(404).json({ error: 'Schedule not found' })
}
// 取消原有的提醒任务(如果存在)
const oldSchedule = schedules[index]
if (oldSchedule.reminderJob) {
oldSchedule.reminderJob.cancel()
}
// 更新日程
const updatedSchedule = {
...schedules[index],
...req.body,
hasNotified: false // 重置提醒状态
}
schedules[index] = updatedSchedule
store.set('scheduleList', schedules)
// 如果是今天的日程且有提醒时间,则创建新的提醒任务
if (updatedSchedule.reminder) {
const job = checkSingleReminder(updatedSchedule)
if (job) {
updatedSchedule.reminderJob = job
store.set('scheduleList', schedules)
}
}
res.json(updatedSchedule)
})
// 删除日程
expressApp.delete('/api/schedules/:id', (req, res) => {
const schedules = store.get('scheduleList', [])
const scheduleToDelete = schedules.find(s => s.id === req.params.id)
if (!scheduleToDelete) {
return res.status(404).json({ error: 'Schedule not found' })
}
// 取消提醒任务(如果存在)
if (scheduleToDelete.reminderJob) {
scheduleToDelete.reminderJob.cancel()
}
const filteredSchedules = schedules.filter(s => s.id !== req.params.id)
store.set('scheduleList', filteredSchedules)
if (scheduleWindow && scheduleWindow.webContents) {
scheduleWindow.webContents.send('schedule-updated')
}
res.status(204).send({
id: scheduleToDelete.id
})
})
// 启动 Express 服务
expressApp.listen(PORT, () => {
console.log(`Schedule API server running on port ${PORT}`)
})
在增加和删除日程中多做了一个处理是往node-schedule中增加定时提醒任务或者删除已有的定时提醒任务。
在晚上十一点五十九分更新第二天的提醒任务
在程序启动的时候只设置了本日的提醒任务, 因此使用node-schedule新建一个定时任务:在每天的23:59更新第二天的提醒任务, 代码如下:
// 当开始的时候
app.whenReady().then(() => {
...
checkTodayReminders();
/* // 每天凌晨检查新的提醒
schedule.scheduleJob('0 0 * * *', () => {
checkTodayReminders()
}) */
// 每天晚上 23:59 更新第二天的日程
schedule.scheduleJob('59 23 * * *', () => {
checkTomorrowReminders();
});
})
const checkTomorrowReminders = () => {
const schedules = store.get('scheduleList', []);
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
// 过滤出明天需要提醒的日程
const tomorrowReminders = expandRecurringSchedules(
schedules,
tomorrow,
dayAfterTomorrow
).filter(scheduleItem => {
if (!scheduleItem.reminder) return false;
const reminderTime = new Date(scheduleItem.reminder);
return reminderTime >= tomorrow && reminderTime < dayAfterTomorrow && !scheduleItem.hasNotified;
});
// 为每个提醒创建定时任务
tomorrowReminders.forEach(scheduleItem => {
const reminderTime = new Date(scheduleItem.reminder);
const job = schedule.scheduleJob(reminderTime, () => {
showReminderNotification(scheduleItem);
// 更新存储中的日程状态
const updatedSchedules = schedules.map(s =>
s.id === scheduleItem.id ? { ...s, hasNotified: true } : s
);
store.set('scheduleList', updatedSchedules);
// 取消任务
job.cancel();
});
});
};
MCP Server 实现
核心实现
MCP Server 主要提供了对日程进行增删查的操作,更改的操作目前我个人觉得没有太大必要,有兴趣的可以参考自行实现。 需要注意的是我在server中提供了一个查询当前时间的tool, 十分简单:
server.tool('get-current-date', '获取当前日期,进行日程操作时先执行这个更新日期', {
}, async () => {
const currentDate = new Date();
const formattedDate = currentDate.toISOString();
return {
content: [{
type: 'text',
text: formattedDate
}]
};
});
为什么要提供这样一个tool? 因为当我没提供这样一个工具去让大模型获取当前时间的时候,当我让它去新建一个今日的日程的时候,它的今日往往是错乱了,可能是2024年6月, 也可能是2024年11月(我猜测与模型相关), 如下图:
而我实际的日期是 2025年5月, 因此我提供了一个获取当前日期信息的tool, 并在描述中让它在执行日程相关操作的时候先执行这个tool,再执行其他tool。
可以看到在Cursor中,它已经能正常理解并且按顺序自动调取使用tool进行操作。
而在Trae中,可能更专注于编码,所以没有能自动识别到关键词。
但是不影响, 我们只要更加精准的发起指令就可以:
可以看到也能够很完善的支持这样的提醒。
我们打开Electron的日程页面: 点击托盘图标或者右击选择打开日程表
日程表界面:
在下午五点五十五分收到提醒:
以下是核心代码实现:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import dotenv from "dotenv";
import fetch from 'node-fetch'
dotenv.config();
// Create server instance
// 创建一个服务端实例
const server = new McpServer({
name: "schedule-electron",
version: "1.0.0",
capabilities: {
resources: {},
tools: {},
},
});
const addUrl = 'http://localhost:3001/api/schedules';
server.tool('add-schedule', '添加日程或提醒,如果用户没有指定结束时间: end,则默认结束时间为开始时间: start或提醒时间: reminder加一小时', {
title: z.string().describe('日程标题'),
start: z.string().describe('开始时间,格式: YYYY-MM-DD HH:mm:ss'),
end: z.string().describe('结束时间,格式: YYYY-MM-DD HH:mm:ss。 用户没指定的时候默认值为开始时间加一小时'),
type: z.string().describe('日程类型,格式为:important: 重要, 日常:normal, 次要:minor, 用户不提及的时候默认为日常'),
reminder: z.string().describe('提醒时间,格式: YYYY-MM-DD HH:mm:ss'),
description: z.string().describe('日程描述'),
repeatType: z.string().describe('重复类型,格式为:daily: 每天, weekly: 每周, monthly: 每月, yearly: 每年 , none: 不重复'),
repeatInterval: z.number().describe('重复间隔,格式为:1, 2, 3, 4, 5, 6, 7, 8, 9, 10'),
repeatDays: z.array(z.number()).describe('重复天数,格式为:[1, 2, 3, 4, 5, 6, 7], 当repeatType为weekly时,该字段代表周的哪几天,从1开始,0代表周日。 当repeatType为monthly时,该字段代表月的哪几天,从1开始,0代表最后一天。 当repeatType为yearly时,该字段代表年的哪几天,从1开始,0代表最后一天。'),
repeatEnd: z.string().describe('重复结束时间,格式: YYYY-MM-DD HH:mm:ss')
}, async ({ title, start, end, type, reminder, description, repeatType, repeatInterval, repeatDays, repeatEnd }) => {
try {
const response = await fetch(addUrl, {
method: 'POST',
body: JSON.stringify({ title: title, start: start, end: end, type: type, reminder: reminder, description: description, repeatType: repeatType, repeatInterval: repeatInterval, repeatDays: repeatDays, repeatEnd: repeatEnd }),
headers: {
'Content-Type': 'application/json'
}
});
const json = await response.json() as any;
return {
content: [{
type: 'text',
text: json.id ? '日程添加成功' : '日程添加失败'
}]
};
}
catch (error: any) {
return {
content: [{
type: 'text',
text: '日程添加失败:' + error.message
}]
};
}
});
server.tool('get-current-date', '获取当前日期,进行日程操作时先执行这个更新日期', {
}, async () => {
const currentDate = new Date();
const formattedDate = currentDate.toISOString();
return {
content: [{
type: 'text',
text: formattedDate
}]
};
});
const getUrl = 'http://localhost:3001/api/schedules/range';
server.tool('get-schedules', '获取日程', {
start: z.string().describe('开始时间,格式: YYYY-MM-DD HH:mm:ss'),
end: z.string().describe('结束时间,格式: YYYY-MM-DD HH:mm:ss')
}, async ({ start, end }) => {
const response = await fetch(`${getUrl}?start=${start}&end=${end}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const json = await response.json() as any;
return {
content: [{
type: 'text',
text: JSON.stringify(json)
}]
};
});
const deleteScheduleUrl = 'http://localhost:3001/api/schedules';
server.tool('delete-schedule', '删除日程', {
id: z.string().describe('日程id')
}, async ({ id }) => {
const response = await fetch(`${deleteScheduleUrl}/${id}`, {
method: 'DELETE'
});
const json = await response.json() as any;
return {
content: [{
type: 'text',
text: json.id ? '日程删除成功' : '日程删除失败'
}]
};
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Schedule MCP Server running on stdio");
}
// 启动
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
查询日程
get-schedules 提供了一个根据起止日期查询日程的方法。使用如下:
删除日程
delete-schedule 提供了一个根据id去删除日程的操作。 例如紧跟上个查询后: 执行删除第一条日程的操作。
其他注意事项
开机自启动
本项目的Electron部分使用了auto-launch 进行开机自启动的申请。 如果不需要的,可以在任务管理器页面进行关闭,或者右击托盘图标,禁止开机自启动:
关于更改日程
我觉得没啥必要,直接删了重设就好,有兴趣的可以自行实现。
关于端口冲突
Express 启动在3001端口, 如果有冲突的,可以自行更改 MCP Server和Electron中的端口中的Express服务。
关于Cursor/Trae中MCP Server的设置
由于我个人环境使用了fnm进行多版本的node管理,因此可能和普通用户的命令不太一样。仅供参考:
总结和优化
我的理解中,MCP Server像是大模型的工具箱, 在这中间给大模型提供了可以使用的工具。 而大模型的智能化,也使得工具的使用更加便利, 例如在本篇中大模型帮我们简化了日期填充,内容填写,事件类型, 是否重复等这些填充的流程,查询的时候也更为简便。
不过本项目也有很多的优化控件,如果Electron版本能改写成VS Code插件的版本那是最好。 实在不行的话,使用Tauri进行精简也不错。
作为一个MCP应用的探索在写的过程也挺好玩的,只是简单在闲暇时间写的demo, 欢迎各位学习交流, 看不上也轻喷。