
1. 为什么我要自己写一个 MCP Server 而不是直接用现成插件Copilot 在 VS Code 里能聊天、能补全、能解释代码但很多人用了一段时间会发现一个尴尬的事实它没法直接帮你算数。你问它帮我算一下 3847 乘以 291 再减去 1560 等于多少它大概率会给你一个看起来很像那么回事、但实际上错得离谱的答案。原因不复杂——大语言模型本质上是概率模型它是在猜下一个 token而不是在算。这就是Tool Calling工具调用要解决的问题。而MCP ServerModel 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输出到 stderrstderr 不参与协议通信可以随便打。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/listServer 返回工具清单。第三阶段是意图识别Copilot 分析用户输入判断需要调用calculate工具并生成参数{expression: 123 456}。第四阶段是工具执行Client 发送tools/callServer 执行计算并返回结果。这四个阶段里前两个是连接建立时自动完成的后两个是每次用户提问时触发的。理解这个流程的价值在于当调用失败时你能快速定位是哪个阶段出了问题。比如 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 更新偶尔会有兼容性问题遇到奇怪报错时可以换官网版本试试。装完之后再验证一下 npmnpm -vnpm 是随 Node.js 一起安装的如果node -v有输出但npm -v报错说明安装过程有问题建议卸载重装。我见过有人因为之前装过旧版本 Node.jsPATH 里残留了旧路径导致node和npm指向不同版本这种问题用where nodeWindows或which nodeMac/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 官方 SDKnpm 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设为trueCopilot 会知道这次调用失败了可能会尝试其他方式或告知用户。4.4 表达式解析为什么不能用 evalevaluateExpression是核心计算逻辑。很多人第一反应是用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 连接这是正常现象按 CtrlC 可以手动结束。5. 把 Server 接入 Copilot配置与调试的完整链路5.1 在 VS Code 里注册 MCP ServerServer 写好了但 Copilot 还不知道它的存在。需要在 VS Code 的配置文件里注册。打开命令面板CtrlShiftP 或 CmdShiftP搜索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.jsVS Code 启动 Server 时的工作目录不是项目目录导致找不到文件。改成绝对路径后解决。如果你不确定绝对路径是什么在项目目录里执行pwdMac/Linux或cdWindows就能看到。第三个坑是SDK 版本不匹配。我参考的教程用的是旧版 SDKAPI 是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里声明工具名、描述和参数 schematools/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 就从一个聊天助手变成了真正能帮你干活的执行者。这个转变带来的效率提升比单纯换个更强的模型要明显得多。