1. 为什么我要自己写一个 MCP Server 而不是直接用现成插件
Copilot 在 VS Code 里能聊天、能补全、能解释代码,但很多人用了一段时间会发现一个尴尬的事实:它没法直接帮你算数。你问它"帮我算一下 3847 乘以 291 再减去 1560 等于多少",它大概率会给你一个看起来很像那么回事、但实际上错得离谱的答案。原因不复杂——大语言模型本质上是概率模型,它是在"猜"下一个 token,而不是在"算"。
这就是Tool Calling(工具调用)要解决的问题。而MCP Server(Model Context Protocol Server)就是目前让 Copilot 这类 AI 助手调用外部工具最主流的方式之一。MCP 是 Anthropic 在 2024 年底开源的一套协议,核心思路很简单:把 AI 助手(MCP Host,比如 VS Code 里的 Copilot)和外部能力(MCP Server,比如你自己写的计算器)用一套标准协议连接起来。Host 负责理解用户意图,Server 负责真正执行任务,两边通过 JSON-RPC 通信。
我选择从零手写一个四则运算的 MCP Server,而不是去装一个现成的,原因有三个。第一,四则运算足够简单,逻辑一目了然,不会让业务代码干扰对协议本身的理解。第二,MCP 的协议细节在官方文档里写得比较抽象,只有自己动手实现一遍initialize、tools/list、tools/call这几个关键方法,才能真正搞明白 Host 和 Server 之间到底在传什么。第三,自己写的 Server 可以完全掌控行为,比如参数校验、错误返回格式、日志输出,这些在调试阶段非常关键。
这篇文章适合两类人:一类是已经装了 VS Code 和 GitHub Copilot、想搞清楚 MCP 到底怎么跑起来的开发者;另一类是想给自己的 AI 工作流加自定义工具、但被协议文档劝退的人。我会用Node.js来写,因为 MCP 官方 SDK 对 Node.js 支持最成熟,而且不需要额外配置编译环境,装好 Node.js 就能跑。整个项目从空文件夹到能在 Copilot 里成功调用,大概需要 40 分钟,前提是你别在环境配置上踩坑——而环境配置恰恰是新手最容易翻车的地方,所以我会把每一步都写清楚。
2. 动手之前必须搞清楚的 MCP 通信模型
2.1 Host、Client、Server 三者到底谁在干什么
很多人第一次接触 MCP 会被 Host、Client、Server 这三个词绕晕。我用一个生活化的类比来解释:把 MCP Host 想象成一家公司的老板(VS Code + Copilot),老板要做决策但不会亲自跑腿;MCP Client 是老板的助理,负责跟外部供应商对接;MCP Server 就是供应商(你写的计算器服务),只负责按订单干活。
具体到代码层面,MCP Host是运行 AI 助手的应用程序,它内置了 MCP Client。MCP Client负责与 Server 建立连接、发送请求、接收响应,它和 Server 通常是一对一的关系。MCP Server是一个独立进程,通过标准输入输出(stdio)或者 HTTP 与 Client 通信。在 VS Code 的场景里,Copilot 扩展就是 Host,它内部会为每个配置的 Server 启动一个 Client 实例。
这里有个关键点容易被忽略:Server 和 Client 之间的通信默认走stdio,也就是标准输入输出流。这意味着你的 Server 进程不能随便往 stdout 里打印调试信息,因为那会污染 JSON-RPC 的消息流,导致协议解析失败。我一开始就是习惯性地用console.log打日志,结果 Copilot 那边一直报解析错误,排查了半小时才发现问题。正确的做法是用console.error输出到 stderr,stderr 不参与协议通信,可以随便打。
2.2 JSON-RPC 2.0 的消息长什么样
MCP 的底层是JSON-RPC 2.0,所有通信都是一个个 JSON 对象。请求消息包含jsonrpc、id、method、params四个字段,响应消息包含jsonrpc、id、result或error。举个实际的例子,当 Copilot 想知道你的 Server 提供了哪些工具时,它会发这样一条消息:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }你的 Server 需要回一条:
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "calculate", "description": "执行四则运算", "inputSchema": { "type": "object", "properties": { "expression": { "type": "string" } }, "required": ["expression"] } } ] } }id字段是请求和响应的关联标识,Client 发过来的id是什么,响应里就必须原样返回什么。这一点在并发场景下尤其重要,如果id对不上,Client 就不知道该把响应交给哪个请求。inputSchema用的是JSON Schema标准,它告诉 AI 这个工具需要什么参数、参数是什么类型、哪些是必填的。Copilot 会根据这个 schema 自动生成调用参数,所以 schema 写得越清晰,AI 调用得越准确。
2.3 一次完整的工具调用要经过哪几个阶段
从用户在 Copilot 对话框里输入"帮我算 123 加 456",到屏幕上显示出结果,中间经历了四个阶段。第一阶段是初始化,Client 发送initialize请求,Server 返回自己支持的协议版本和能力声明。第二阶段是工具发现,Client 发送tools/list,Server 返回工具清单。第三阶段是意图识别,Copilot 分析用户输入,判断需要调用calculate工具,并生成参数{"expression": "123 + 456"}。第四阶段是工具执行,Client 发送tools/call,Server 执行计算并返回结果。
这四个阶段里,前两个是连接建立时自动完成的,后两个是每次用户提问时触发的。理解这个流程的价值在于:当调用失败时,你能快速定位是哪个阶段出了问题。比如 Copilot 说"没有找到可用工具",那问题大概率在tools/list阶段;如果 Copilot 说"工具执行失败",那问题在tools/call阶段。我在调试时就是靠这个分层思路,把问题从"整个不工作"缩小到"initialize 返回的协议版本不对",效率高很多。
3. 环境搭建:Node.js 和 VS Code 的版本坑
3.1 Node.js 版本选择与安装验证
MCP 官方 SDK 要求Node.js 18 及以上,我实测下来 18.x 和 20.x 都能正常跑,但 16.x 会在加载 SDK 时直接报错。如果你不确定自己装的是哪个版本,打开终端执行:
node -v如果输出是v16.x.x或者更低,建议去 Node.js 官网下载 LTS 版本重新安装。安装时有个细节:Windows 用户务必勾选"Add to PATH",否则装完之后终端里还是找不到node命令。Mac 用户如果用 Homebrew,直接brew install node就行,但要注意 Homebrew 装的版本可能比官网 LTS 更新,偶尔会有兼容性问题,遇到奇怪报错时可以换官网版本试试。
装完之后再验证一下 npm:
npm -vnpm 是随 Node.js 一起安装的,如果node -v有输出但npm -v报错,说明安装过程有问题,建议卸载重装。我见过有人因为之前装过旧版本 Node.js,PATH 里残留了旧路径,导致node和npm指向不同版本,这种问题用where node(Windows)或which node(Mac/Linux)就能看出来。
3.2 VS Code 与 Copilot 的配置检查
VS Code 本身没什么特殊要求,稳定版即可。关键是GitHub Copilot扩展要更新到较新版本,因为 MCP 支持是逐步加入的,老版本可能根本没有相关配置项。在扩展面板里搜索 Copilot,确认已安装且已登录。登录状态可以在 VS Code 左下角的账户图标里查看,如果显示未登录,点击登录并按提示完成授权。
这里有个常见问题:有些人装了 Copilot 但对话功能用不了,或者对话历史丢失。这种情况通常是扩展版本和 VS Code 版本不匹配导致的。我的建议是先把 VS Code 更新到最新稳定版,再更新 Copilot 扩展,最后重启一次。如果还是不行,可以尝试在命令面板里执行"Developer: Reload Window"强制重载。另外,Copilot 的 MCP 配置入口在不同版本里位置可能不一样,有的在设置里的mcp相关项,有的需要通过settings.json手动配置,下面我会给出具体的配置写法。
3.3 项目初始化与依赖安装
新建一个空文件夹,比如叫mcp-calculator,然后在终端里进入这个目录,执行:
npm init -y这会生成一个默认的package.json。接着安装 MCP 官方 SDK:
npm install @modelcontextprotocol/sdk安装完成后,package.json的dependencies里应该能看到@modelcontextprotocol/sdk。这里要注意,SDK 的版本更新比较快,不同版本 API 可能有细微差异。如果你照着某篇教程写代码报错说某个方法不存在,先检查一下 SDK 版本。可以在package.json里锁定一个已知稳定的版本,比如"@modelcontextprotocol/sdk": "^1.0.0",避免自动升级到不兼容的新版本。
另外建议在package.json里加上"type": "module",这样就能用 ES Module 的import语法,和 SDK 的示例代码保持一致。如果坚持用 CommonJS 的require,也不是不行,但需要额外处理一些模块解析问题,新手容易在这里卡住,所以我还是推荐用 ESM。
4. 核心代码:从 initialize 到 tools/call 的完整实现
4.1 创建 Server 实例与声明工具能力
先创建一个server.js文件,开头导入必要的模块:
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";然后创建 Server 实例:
const server = new Server( { name: "calculator-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } );这里的capabilities声明了你的 Server 支持哪些能力。tools: {}表示支持工具调用。如果你以后想加资源(resources)或提示(prompts),也在这里声明。name和version是给 Client 看的标识信息,随便起但建议有意义,方便调试时辨认。
4.2 实现 tools/list:告诉 Copilot 你有哪些工具
接下来注册tools/list的处理函数:
server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "calculate", description: "执行四则运算,支持加、减、乘、除和括号。输入一个数学表达式字符串,返回计算结果。", inputSchema: { type: "object", properties: { expression: { type: "string", description: "要计算的数学表达式,例如 '123 + 456' 或 '(10 * 5) / 2'", }, }, required: ["expression"], }, }, ], }; });description字段非常关键,Copilot 就是靠它来判断什么时候该调用这个工具。写得越具体,AI 判断得越准。我一开始把 description 写成"计算器",结果 Copilot 经常在不需要计算的时候也去调用它。后来改成明确说明"执行四则运算"并给出输入格式示例,误调用率明显下降。inputSchema里的description同样重要,它指导 AI 如何构造参数。
4.3 实现 tools/call:真正执行计算逻辑
然后是tools/call的处理函数:
server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name !== "calculate") { throw new Error(`未知工具: ${name}`); } const expression = args.expression; if (typeof expression !== "string" || expression.trim() === "") { throw new Error("表达式必须是非空字符串"); } try { const result = evaluateExpression(expression); return { content: [ { type: "text", text: `计算结果: ${result}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `计算失败: ${error.message}`, }, ], isError: true, }; } });注意返回值的结构:content是一个数组,每个元素有type和text。这是 MCP 规定的格式,Client 会解析这个数组并把文本展示给用户。如果出错,把isError设为true,Copilot 会知道这次调用失败了,可能会尝试其他方式或告知用户。
4.4 表达式解析:为什么不能用 eval
evaluateExpression是核心计算逻辑。很多人第一反应是用eval(),但这是极其危险的做法。eval会执行任意 JavaScript 代码,如果用户输入的是process.exit()或者更恶意的代码,你的整个进程就完了。虽然在这个场景里用户就是你自己,但养成好习惯很重要,而且 Copilot 生成的参数你也不能百分百信任。
我选择手写一个简单的解析器,支持加减乘除和括号。核心思路是调度场算法(Shunting Yard Algorithm):把中缀表达式转成后缀表达式,再计算后缀表达式。这个算法不复杂,但能正确处理运算符优先级和括号。下面是一个精简实现:
function evaluateExpression(expr) { const tokens = expr.match(/\d+\.?\d*|[+\-*/()]/g); if (!tokens) throw new Error("无效的表达式"); const output = []; const operators = []; const precedence = { "+": 1, "-": 1, "*": 2, "/": 2 }; for (const token of tokens) { if (/^\d/.test(token)) { output.push(parseFloat(token)); } else if (token === "(") { operators.push(token); } else if (token === ")") { while (operators.length && operators[operators.length - 1] !== "(") { output.push(operators.pop()); } if (!operators.length) throw new Error("括号不匹配"); operators.pop(); } else { while ( operators.length && operators[operators.length - 1] !== "(" && precedence[operators[operators.length - 1]] >= precedence[token] ) { output.push(operators.pop()); } operators.push(token); } } while (operators.length) { const op = operators.pop(); if (op === "(") throw new Error("括号不匹配"); output.push(op); } const stack = []; for (const token of output) { if (typeof token === "number") { stack.push(token); } else { const b = stack.pop(); const a = stack.pop(); if (a === undefined || b === undefined) throw new Error("表达式格式错误"); switch (token) { case "+": stack.push(a + b); break; case "-": stack.push(a - b); break; case "*": stack.push(a * b); break; case "/": if (b === 0) throw new Error("除数不能为零"); stack.push(a / b); break; } } } if (stack.length !== 1) throw new Error("表达式格式错误"); return stack[0]; }这段代码处理了运算符优先级、括号嵌套、除零错误和格式校验。实测下来,(10 + 5) * 3 - 8 / 2这种复杂表达式也能正确算出41。如果你想要更完善的功能,比如支持幂运算或函数调用,可以在这个基础上扩展,但四则运算的场景已经够用了。
4.5 启动 Server 并连接 stdio 传输层
最后是启动代码:
const transport = new StdioServerTransport(); await server.connect(transport); console.error("Calculator MCP Server 已启动");注意这里用console.error而不是console.log,原因前面说过,stdout 被协议占用了。StdioServerTransport会自动处理消息的读取和写入,你不需要手动管理流。await server.connect(transport)之后,Server 就开始监听来自 Client 的消息了。
完整的server.js写完后,在package.json里加一个启动脚本:
"scripts": { "start": "node server.js" }然后执行npm start,如果看到 stderr 输出"Calculator MCP Server 已启动"且进程没有退出,说明 Server 本身没问题。注意它不会主动退出,因为它在等待 Client 连接,这是正常现象,按 Ctrl+C 可以手动结束。
5. 把 Server 接入 Copilot:配置与调试的完整链路
5.1 在 VS Code 里注册 MCP Server
Server 写好了,但 Copilot 还不知道它的存在。需要在 VS Code 的配置文件里注册。打开命令面板(Ctrl+Shift+P 或 Cmd+Shift+P),搜索"Preferences: Open User Settings (JSON)",在settings.json里加入:
{ "mcp.servers": { "calculator": { "command": "node", "args": ["/absolute/path/to/mcp-calculator/server.js"] } } }这里的args必须是绝对路径,相对路径在 VS Code 启动 Server 时解析会出问题。Windows 用户注意路径里的反斜杠要写成双反斜杠或者正斜杠,比如C:/Users/yourname/mcp-calculator/server.js。配置保存后,重启 VS Code 让配置生效。
不同版本的 Copilot 扩展对 MCP 配置的键名可能略有差异,有的版本用github.copilot.mcp.servers,有的用mcp.servers。如果你配置后 Copilot 没反应,可以先检查一下当前版本用的是哪个键名。在设置里搜索"mcp"通常能看到相关项。
5.2 验证连接是否成功
重启 VS Code 后,打开 Copilot 对话面板,输入"帮我算一下 123 加 456"。如果一切正常,Copilot 会显示它调用了calculate工具,并返回579。如果没反应,按下面的顺序排查。
第一步,确认 Server 进程是否被启动。打开任务管理器(Windows)或活动监视器(Mac),搜索node进程。如果 Copilot 配置正确,它会在需要时启动 Server 进程。如果完全看不到 node 进程,说明配置没生效,检查settings.json的路径和键名。
第二步,看 Copilot 的输出日志。在 VS Code 的输出面板里,选择"GitHub Copilot"或"MCP"相关的通道,里面会打印 Client 和 Server 之间的通信日志。如果看到initialize请求但没有响应,说明 Server 启动后崩溃了,大概率是代码里有语法错误或依赖没装好。
第三步,手动测试 Server。在终端里执行node server.js,然后手动输入一行 JSON-RPC 请求,看有没有正确响应。比如输入:
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}按回车后应该看到工具列表的 JSON 输出。如果没输出或报错,问题就在 Server 代码本身,跟 Copilot 配置无关。
5.3 我踩过的三个典型坑
第一个坑是stdout 污染。前面提过,我在代码里加了console.log打调试信息,结果 Copilot 一直报协议解析错误。排查时我盯着 Server 代码看了半天,最后才意识到是日志输出到了 stdout。改成console.error后立刻正常。这个坑的教训是:MCP Server 里任何非协议输出都必须走 stderr。
第二个坑是路径问题。我一开始在settings.json里写了相对路径./server.js,VS Code 启动 Server 时的工作目录不是项目目录,导致找不到文件。改成绝对路径后解决。如果你不确定绝对路径是什么,在项目目录里执行pwd(Mac/Linux)或cd(Windows)就能看到。
第三个坑是SDK 版本不匹配。我参考的教程用的是旧版 SDK,API 是server.setRequestHandler,但我装的新版 SDK 改成了别的写法,导致代码直接报错。解决办法是去 npm 页面看当前版本的 README,或者把 SDK 版本锁定到教程对应的版本。这个坑提醒我,涉及快速迭代的库时,一定要确认版本。
5.4 让 Copilot 更准确地调用工具
工具能跑通之后,下一步是让它调用得更准。我做了两个优化。第一个是丰富 description,把"执行四则运算"扩展成"执行四则运算,支持加、减、乘、除和括号。输入一个数学表达式字符串,返回计算结果",并给参数也加上说明。这样 Copilot 在判断是否调用时有了更多依据。
第二个是限制调用范围。我在 description 里明确写了"仅用于数学计算,不要用于其他用途",减少误调用。实测下来,加了这句话之后,Copilot 在闲聊时基本不会再去调用计算器了。如果你发现 Copilot 频繁误调用,可以在 description 里加一些反例说明,比如"不要用于日期计算或单位换算"。
还有一个技巧是在 Server 端做参数校验并返回友好错误。Copilot 拿到错误信息后,有时会自动修正参数重试。比如用户输入了"一百加二百",Copilot 可能生成{"expression": "一百加二百"},我的 Server 返回"表达式必须是非空字符串"或"无效的表达式",Copilot 看到后会尝试转换成数字再调用。这种"错误驱动"的交互能提升整体成功率。
6. 从四则运算扩展到真实业务工具的改造思路
6.1 把计算逻辑换成你自己的业务逻辑
四则运算只是个引子,真正有价值的是把这套模式套用到你自己的业务上。假设你想让 Copilot 能查询公司内部的订单状态,只需要把calculate换成queryOrder,把表达式解析换成数据库查询。核心结构完全不变:tools/list里声明工具名、描述和参数 schema,tools/call里执行实际逻辑并返回结果。
改造时要注意几点。第一,参数 schema 要尽量精确,比如订单号是字符串、日期是 ISO 格式,这些都要在 schema 里写清楚,Copilot 才能生成正确的参数。第二,返回值要结构化,不要返回一大坨文本,而是返回 JSON 字符串,让 Copilot 能解析出关键字段。第三,错误处理要完善,数据库连不上、订单不存在、权限不足,这些都要有明确的错误信息返回,而不是让进程崩溃。
6.2 多工具场景下的命名与组织
当你的 Server 提供多个工具时,命名就变得重要了。建议用"动词+名词"的格式,比如queryOrder、createTicket、sendNotification,让 Copilot 一眼就能理解每个工具的用途。如果工具很多,可以在 description 里加上分类前缀,比如"[订单] 查询订单状态",帮助 AI 更快定位。
另外,多个工具之间如果有依赖关系,比如先查询订单再修改订单,可以在 description 里说明前置条件。Copilot 目前对工具链的编排能力还在进化中,明确的说明能显著提升多步任务的完成率。我实测过一个"查询订单然后发送通知"的两步任务,在 description 里写清楚依赖关系后,Copilot 能正确按顺序调用两个工具。
6.3 日志、监控与安全边界
生产环境使用 MCP Server 时,日志和监控不能少。由于 stdout 被协议占用,所有日志走 stderr,建议用console.error配合时间戳和级别前缀,方便排查。如果 Server 要长期运行,可以考虑把日志写到文件里,避免 stderr 缓冲区满了导致进程阻塞。
安全方面,最重要的一条是永远不要信任 AI 生成的参数。即使 schema 里声明了类型,也要在代码里做二次校验。比如声明了expression是字符串,但 AI 可能传过来一个超长字符串或者包含特殊字符的内容,你的解析器要能处理这些边界情况。另外,如果工具涉及敏感操作(比如删除数据、发送消息),建议加上确认机制,或者在 description 里明确标注"此操作不可逆",让 Copilot 在调用前提醒用户。
我在实际使用中的一个体会是,MCP Server 的价值不在于工具本身多复杂,而在于它把"AI 能做什么"的边界从"模型训练时见过的知识"扩展到了"你手头能调用的任何能力"。四则运算只是个开始,当你把公司内部的 API、数据库、脚本都包装成 MCP 工具之后,Copilot 就从一个聊天助手变成了真正能帮你干活的执行者。这个转变带来的效率提升,比单纯换个更强的模型要明显得多。