本仓库的目标是使用Bun运行时原型化一个MCP(模型上下文协议)服务器,并通过VS Code Copilot聊天代理模式使用其工具。

我首先阅读了Model Context Protocol文档。 根据MCP文档中的术语,我们将使用以下元素:
对于此设置,文档提供了一个特定的学习路径:
MCP服务器通过暴露类似于REST端点的工具来工作,而MCP客户端使用的LLM可以根据用户提示是否可以从这些工具提供的数据中受益来调用这些工具。
文档页面提供了一个示例天气服务MCP服务器,这是一个很好的例子,因为LLM无法提供天气信息,因为天气预报是一个实时数据,因此LLM无法推断出这些信息。
我将构建一个能够管理Markdown文件中待办事项列表的MCP服务器。该MCP服务器的目标是通过允许用户添加、切换和删除待办事项项,为用户提供创建和维护文档所需的工具。
值得一提的是,MCP服务器可以向MCP客户端提供不同类型的资源:
正如文档页面所述,我的MCP服务器将专注于仅提供工具。
与文档页面不同,我将使用Bun而不是Node。 尽管如此,我还是可能从参考实现这里获益。
教程页面包括一个基于SDK包的服务器实现。我将首先采用这种方法,但最终我希望从零开始构建一个服务器,实现原始协议。
据我理解,这应该不会太难,因为服务器可以通过标准I/O以及服务器发送事件进行通信,这两种方式都很容易实现且不需要依赖。
在此之前,我将通过bun add @modelcontextprotocol/sdk命令将@modelcontextprotocol/sdk包作为依赖添加。
Bun会创建package.json文件,就像我所有的个人项目一样,我会将版本更改为latest,以便项目不会停留在旧版本上。
package.json:
{
"dependencies": {
"@modelcontextprotocol/sdk": "latest"
}
}
我还添加了一个.gitignore文件,并忽略了node_modules和bun.lock。我不做依赖项供应商,并且不需要锁文件,因为所有依赖项都应该始终安装在其最新版本上。
服务器的核心部分将位于名为index.ts的新文件中。我认为在客户端配置中,我可以指定服务器通过命令和参数,如果真是这样,只需指定bun 作为命令和参数对即可非常方便。
我的脚本将是ESM,并且没有构建步骤,就像通常那样。它将使用Bun的内置TypeScript支持,并且不会覆盖Bun的默认TypeScript配置。
这个基本的MCP SDK import和服务器清单规范如下所示:
index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({
name: "to-do",
version: "0.0.0",
capabilities: {
tools: {},
},
});
下一步是注册MCP服务器要暴露的工具。我将从一个开始——list-todos,它将返回硬编码的数据。
server.tool("list-todos", "列出TODO.md文件中的所有待办事项", () => {
return {
content: [
{
type: "text",
text: "- [ ] 做饭\n- [ ] 去买菜\n- [ ] 计划周末旅行",
},
],
};
});
有了工具定义后,现在需要配置服务器传输,以便客户端知道如何与服务器通信。这需要在文件顶部添加一个新的import,我选择使用标准I/O。
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// …
const transport = new StdioServerTransport();
await server.connect(transport);
此时,是时候测试MCP服务器了。文档页面展示了如何使用Claude进行测试,所以我将在VS Code中学习如何配置这一点。
VS Code关于MCP的文档列出了几种选项: https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server
我更喜欢工作区配置选项,所以我已经在该仓库的工作区中创建了一个空的.vscode/mcp.json文件,并在新的VS Code标签页中打开了它。
VS Code识别这个特殊路径并在文件编辑器区域右下角显示了一个标题为“添加服务器”的按钮。
我点击了该按钮并选择了命令(标准I/O)选项。
此时,必须进行一些调整和实验。我尝试的第一个命令只是bun,但我没有意识到这个流程不会询问我命令的参数。它唯一询问的是服务器名称,我输入了to-do。
服务器未能启动,在MCP: to-do通道的输出面板中可以看到原因——VS Code主机执行了命令并开始解释其标准I/O流用于传输。
但由于命令只是bun,当没有提供或发现脚本时,它打印了默认输出。这些行无法解析为MCP消息,因此连接未能建立。
这是错误的配置:
{
"servers": {
"to-do": {
"type": "stdio",
"command": "bun",
"args": []
}
}
}
此时,我开始调整JSON代码本身,而不是依赖于VS Code UI流程。
我想一个简单的修复方法可能是将命令更改为bun .,以便Bun被调用并尝试启动index.ts。
我点击了VS Code放置在服务器条目上方的“重启”代码透镜。这再次导致了一个错误。
看来定义为命令的MCP服务器不在工作区目录中运行。为了验证这一点,我将命令更改为pwd并重新运行。这打印了/Users/tom,证实了我的怀疑。
从这里开始有多种选择。我不想将脚本的完整路径硬编码为Bun的参数,所以我认为我可以依赖VS Code配置替换: https://code.visualstudio.com/docs/reference/variables-reference
我将命令更改为echo '${workspaceFolder}',以查看它是否会打印变量值到输出窗口,并再次点击“重启”。
这打印了预期的目录!当然,服务器仍然没有启动,但此时我知道最终的命令应该是bun ${workspaceFolder}。我惊讶地看到这不起作用,但很快意识到我应该将workspaceFolder变量作为参数传递到args字段中。
我再次将命令更改为echo,确保一切仍按预期工作:
{
"servers": {
"to-do": {
"type": "stdio",
"command": "echo",
"args": ["${workspaceFolder}"]
}
}
}
这打印了[warning] Failed to parse message: "/Users/tom/Desktop/bun-mcp\n",让我知道变量替换和命令传递工作正常。
我现在可以将command更改为bun,让服务器启动。服务器条目上方的代码透镜变为“正在运行……”,并且我在输出标签页中看到了一系列活动:
[info] 连接状态:启动
[info] 从LocalProcess扩展主机启动服务器
[info] 连接状态:启动
[info] 连接状态:运行
[info] 发现1个工具
这意味着我的MCP服务器及其唯一的工具已被发现,我可以在VS Code GitHub Copilot聊天窗格中以代理模式尝试触发它的提示。
我通过VS Code窗口顶部中央命令栏右侧的Copilot图标打开了GitHub Copilot聊天。
它一开始处于“询问”模式,所以我切换到了“代理”。这给聊天作曲家带来了一些新图标:开始语音聊天、选择工具和发现工具/新工具可用(如不适用则隐藏)。
我点击了“选择工具”按钮,并检查打开的列表,确认to-do服务器及其list-todos工具被列出并选中!
这意味着我的提示现在应该能够使用这个工具。我问Copilot:
我的待办事项列表里有哪些?
它要么不够聪明,要么太聪明了,因为它使用了README文件中的复选框列表(该文件自动包含在聊天中作为参考,如提示输入区域顶部的项目所示),并回答了列出的待办事项复选框。
我点击了readme.md旁边的“眼睛”图标,从提示上下文中移除了当前文件引用,并重新运行了相同的提示。
这次,Copilot Chat询问我是否允许to-do MCP服务器运行list-todos工具,并提供了“继续”和“取消”按钮。我选择了“继续”按钮旁边的箭头,并选择了“在此工作区中始终允许”,以便从此目录中的仓库目录开始,我的MCP服务器可以不间断地运行。
Copilot Chat更改以指示它运行了我的to-do服务器的list-todos工具,并回应道:
您当前的待办事项列表包含以下项目:
<input disabled="" type="checkbox">做饭<input disabled="" type="checkbox">去买菜<input disabled="" type="checkbox">计划周末旅行 如果您想添加、删除或更新这些任务,请告诉我!
有趣的是,Markdown复选框在Copilot聊天UI中无法渲染,所以我认为在更改工具以不返回硬编码数据时,我需要使用表情符号。
此外,尽管我的MCP服务器尚未公开用于编辑此列表的工具,Copilot似乎自信地认为它可以帮我编辑这个列表。我将其归因于幻觉/与MCP服务器交互的提示不足。
下一步,我将添加一个用于创建新待办事项的工具,并更改现有用于列出待办事项的工具,使用内存存储,以便我可以添加和列出新的待办事项。
当涉及到带参数注册工具时,MCP SDK似乎与Zod绑定,用于验证MCP工具参数结构的模式验证。我不喜欢这一点,并且在我未来的手动实现MCP协议的实现中,我将放弃Zod依赖,但目前我将遵守并使用它:
bun add zod
package.json:
{
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
"zod": "lates"
}
}
这是我如何更改index.ts以实现这一点的:
import { z } from "zod";
// …
const todos: { name: string; isChecked: boolean }[] = [];
server.tool("list-todos", "列出TODO.md文件中的所有待办事项", () => {
return {
content: [
{
type: "text",
text: todos
.map((todo) => `${todo.isChecked ? "✅" : "❎"} ${todo.name}`)
.join("\n"),
},
],
};
});
server.tool(
"add-todo",
"向TODO.md文件添加新的待办事项",
{
name: z.string().describe("待办事项的名称"),
},
({ name }) => {
const newTodo = { name, isChecked: false };
todos.push(newTodo);
return {
content: [
{
type: “text”,
text: `已添加新的待办事项:${newTodo.name}`,
},
],
};
}
);
// …
做出这些更改后,我又回到了mcp.json标签页,并点击了“重启”代码透镜操作,使更改生效。
另一个代码透镜项变为“2个工具”,表明更改确实发生了,进一步由输出面板中MCP: to-do通道的日志所证实,现在增加了这些行:
[info] 停止服务器to-do
[info] 连接状态:停止
[info] 启动服务器to-do
[info] 连接状态:启动
[info] 从LocalProcess扩展主机启动服务器
[info] 连接状态:启动
[info] 连接状态:运行
[info] 发现2个工具
我将当前文件引用保持在Copilot聊天上下文之外,并请求它为我添加一个新的待办事项:
添加一个待办事项,晚餐买菜
我再次被提示选择“继续”或“取消”to-do MCP服务器调用,这次是为了add-todo工具,这让我感到惊讶,我猜这个检查是按工具而不是按服务器进行的,这超出了我的初始预期。
我再次选择了“继续”下拉菜单中的“在此工作区中始终允许”选项。
Copilot回应道:
新的待办事项“晚餐买菜”已添加到您的列表中。 如果您需要查看、更新或删除任何待办事项,请告诉我!
我展开Copilot响应顶部的“运行add-todo”框以深入查看。它打印了根据提示生成的输入以及来自MCP服务器的输出。
输入:
{
"name": "晚餐买菜"
}
输出:
已添加新的待办事项:晚餐买菜
接下来,我询问我的待办事项列表里有哪些:
我的待办事项列表里有哪些?
这次,我没有被提示允许工具运行,它无中断地运行了,因此提示确实似乎是按工具进行的。
Copilot回应道:
您当前的待办事项列表包含:
<input disabled="" type="checkbox">晚餐买菜 如果您需要添加、删除或更新任何待办事项,请告诉我!
检查工具调用框的详细信息:
输入:
{}
输出:
❎ 晚餐买菜
看来Copilot LLM采取了这个工具响应并自行将其格式化为Markdown复选框列表,这意味着我们再次遇到了VS Code Copilot Chat中Markdown渲染的问题。
我不确定仅靠工具能否防止这种情况,但MCP服务器可以公开一个提示,将简单的待办事项列表视图查询转换为更丰富的查询,指示Copilot以某种方式格式化列表,可能使用我在代码中使用的表情符号,而不是Markdown复选框列表。
要使待办事项列表管理流程完整,还需要切换和删除待办事项项。
我添加了一个基于关键词切换待办事项项的工具,如下所示:
server.tool(
"toggle-todo",
"切换待办事项项的完成状态",
{
keyword: z
.string()
.describe("待切换待办事项项名称中的关键词"),
},
({ keyword }) => {
const todo = todos.find((todo) => todo.name.includes(keyword));
if (!todo) {
return {
content: [
{
type: "text",
text: `未找到包含"${keyword}"的待办事项项。`,
},
],
};
}
todo.isChecked = !todo.isChecked;
return {
content: [
{
type: "text",
text: `切换待办事项项"${todo.name}"的状态为 ${
todo.isChecked ? "已完成" : "未完成"
}。`,
},
],
};
}
);
我在VS Code中重启了MCP服务器,并请求Copilot:
划掉晚餐的待办事项项
我确认了toggle-todo工具调用的“在此工作区中始终允许”,并得到了回复:
看起来包含“晚餐”的待办事项项未找到。 请确认待办事项的确切措辞,或者告诉我它最近是否被更改或删除?
我意识到问题是由于MCP服务器重启丢失了内存存储状态,因此还有另一件事要做:持久化。
我计划用I/O辅助函数替换todos常量,以读取和写入待办事项项到存储中。
为了实现这些辅助函数,我使用了Bun的I/O方法,这需要我将Bun类型添加到依赖项中,以便在TypeScript中访问I/O方法:
bun add -D @types/bun
package.json:
{
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
"zod": "latest"
},
"devDependencies": {
"@types/bun": "latest"
}
}
我使用import Bun from "bun";导入了Bun,并意识到为了编写I/O辅助函数,我需要确保脚本知道要使用的目录路径。
我假设脚本的工作目录是/Users/tom,因为我们通过VS Code工作区的完整路径调用了bun,因为它从那个目录开始。
我决定还是验证一下,通过将默认的todos更改为列出脚本运行所在目录的完整路径:
const todos: { name: string; isChecked: boolean }[] = [
{
name: `知道工作目录是${import.meta.dirname}`,
isChecked: true,
},
];
我重启了服务器,并请求Copilot我的待办事项是什么,它说:
您当前的待办事项列表包含:
<input checked="" disabled="" type="checkbox">知道工作目录是/Users/tom/Desktop/bun-mcp 如果您需要添加、删除或更新任何待办事项,请告诉我!
所以,令人惊讶的是(对我来说),如果通过目录路径调用脚本,它会将工作目录设置为该路径。这简化了一点事情。
我用这段代码替换了todos常量:
const FILE_PATH = "TODO.md";
async function readTodos() {
const text = await Bun.file(FILE_PATH).text();
const lines = text.split("\n").filter((line) => line.trim() !== "");
return lines.map((line) => {
const isChecked = line.startsWith("- [x] ");
const name = line.slice("- [?] ".length).trim();
return { name, isChecked };
});
}
async function writeTodos(todos: Awaited<ReturnType<typeof readTodos>>) {
const content = todos
.map((todo) => `- [${todo.isChecked ? "x" : " "}] ${todo.name}`)
.join("\n");
await Bun.write(FILE_PATH, content);
}
我还更新了三个现有的工具,使其回调方法为async,并添加了const todos = await readTodos(),以使todos的使用再次有效,并在待办事项项被修改时添加await writeTodos(todos)以