聊 vibe coding,我见过最多的翻车现场,是开发者把需求原封不动丢给 AI,然后端着一杯咖啡等它变出整个项目。结果通常是:第一遍编译失败,第二遍依赖冲突,第三遍改坏了之前的逻辑,最后只能宣布"AI 写不了大项目"。这还真不是 AI 能力的问题,是打开方式的问题。
我这一年反复实践下来,比较靠谱的理解是:vibe coding 不是"无脑让 AI 写",而是"你定边界、AI 干体力活、系统给反馈"。而要把这个闭环做得顺畅,MCP(Model Context Protocol,模型上下文协议)是一个极其关键的基础设施。它能让 AI 不靠猜,直接调用你本地的工具和数据。
这篇文章我会从零开始,手把手带你把一个 MCP 服务开发出来,并把 vibe coding 的正确工作流揉进每一步——包括怎么给 AI 写任务规格、怎么测试、怎么排错。不管你是后端、前端、还是刚入门没多久的开发者,只要跟着走完,都能跑出自己的第一个 MCP 服务,并且从此对"让 AI 真正干活"这件事有完全不一样的理解。
1. 先想清楚:vibe coding 的"正确打开方式"到底是什么
1.1 vibe coding 最常见的误区:把"聊天"当成"协作"
很多人对 vibe coding 的理解就是:打开一个 AI 编程工具,用自然语言描述需求,AI 输出一大段代码,然后复制粘贴跑起来。这种"一问一答"模式在写脚本、写函数的时候很好用,但一旦项目变大,就会立刻露馅。
我举一个实际例子。有人让 AI 做一个"团队内部用的项目周报生成器",需求大概是"能选项目、能填工时、能导出 Markdown"。听起来很简单,对吧?AI 也确实给出了一个看起来完整的页面和脚本。但接下来呢?路由写少了、状态管理跟需求不匹配、导出格式跟实际表格对不上、依赖版本和本机环境冲突。而这些错误,都发生在 AI 看不到真实项目上下文的前提下。
AI 的世界里只有你给它的那几段对话,它猜不到你的项目目录结构、猜不到你的代码规范、猜不到你的真实数据格式。所以 vibe coding 的正确姿势根本不是"把需求交给 AI",而是"把任务边界和真凭实据交给 AI"。它负责把已经明确的东西快速实现,你负责提供足够真实的上下文和检验标准。
你可以把它想象成带新人。新人上手最好的方式,不是你让他"随便看看把公司业务理解一下",而是给他明确的任务、真实的工具、以及验证结果的方法。vibe coding 也是一样的逻辑:AI 是那个有手速、没常识的新人,MCP 是它连接真实系统的"工作台"。
1.2 MCP 是什么:给 AI 装一个通用的外设接口
MCP 的全称是 Model Context Protocol,模型上下文协议。翻译成人话就是:一套标准化的规则,让 AI 应用(也就是客户端)能通过统一的方式去调用外部的工具、读取外部的数据。
以前,每个 AI 工具都要做一堆私有接口,你要给它接一个数据源,就得写一个专属集成。这就像每个电脑品牌都用自己的充电口,特别痛苦。MCP 做的就是把接口标准化,你可以把任意能力封装成一个"服务",任何支持 MCP 的客户端都能即插即用。对,就是那个被说烂了的比喻:USB-C 之于 AI。
我特别想强调的是这个协议里三个你可能经常听到的概念:
- 工具(Tools):模型可以主动调用的函数,比如"扫描目录""执行 Git 查询""查天气"。这个是这篇文章的重头戏。
- 资源(Resources):模型可以读取的数据,比如一个配置文件、一份文档、一张表。
- 提示词模板(Prompts):预先写好的、可复用的提问模板。
协议本身用的是 JSON-RPC 2.0,走的是消息式的请求/响应。开发者在自己的机器上跑一个服务端程序,客户端启动时会跟它握手,然后客户端问它"你有什么工具",模型觉得需要时就让客户端调用某个工具,最后把结果塞回给模型继续推理。整个过程对使用者来说是无感的。
所以 MCP 对 vibe coding 的意义在哪?答案四个字:真实反馈。AI 不需要再靠想象力回答"这个项目里有没有未完成的 TODO",它可以直接调用你的 MCP 工具去扫描;AI 也不需要靠猜来告诉你"昨天的改动有多大影响",它可以直接读 Git 状态。有了真实反馈,AI 的每一轮输出质量都会高一个量级。
2. 动手前的设计:选题、分工和技术选型
2.1 做一个"有用又不复杂"的 MCP 服务:从需求到接口
我见过很多人第一次做 MCP,上来就想做一个"全能助手",什么数据都接、什么工具都放。结果代码量大到失控,运行半天连工具列表都加载不出来。第一次做 MCP 服务的正确姿势,是挑一个你觉得"要是 AI 能直接调它就爽了"的小功能。
我这次选的主题是"开发助手",专门帮 AI 快速了解一个代码仓库。这非常贴合 vibe coding 的使用场景:你正在让 AI 帮你改代码,AI 却对你的项目毫无概念,这时候它如果能自己调用工具去扫描源码、看 Git 状态,整个协作效率完全是两回事。
我们计划实现两个工具:
| 工具名 | 入参 | 输出 | 解决的问题 |
|---|---|---|---|
| scan_todos | 项目目录路径、要匹配的关键字(默认 TODO) | 所有命中注释的文件、行号、内容摘要 | AI 快速知道现在有多少历史遗留工作 |
| git_status_summary | 仓库路径 | 当前分支、工作区改动数、暂存区改动数、最近一次提交信息 | AI 快速掌握代码库当前状态 |
这个选题还有一个好处:文件系统和 Git 命令都是确定性行为,结果可以人工验证。一旦服务跑起来,你可以先用命令行手动执行同样的逻辑,再让 AI 调用工具,两边的结果一对比,你就知道服务有没有写对。这种"可验证性",是 vibe coding 项目最珍贵的东西。
2.2 技术选型:为什么是 TypeScript + 官方 SDK
开发 MCP 服务,可选的路有很多:直接用协议手搓、用官方 SDK、用社区封装库,语言可以用 TypeScript、Python、Go、Java。我建议第一次尝试就用 TypeScript + MCP 官方 SDK,原因很实际。
第一,官方 SDK 帮你封装了握手、生命周期、消息解析这些脏活。如果你手搓 JSON-RPC,虽然能更深刻理解协议,但也很容易在细节上踩坑,比如某个消息的字段名不对、初始化顺序错了,客户端直接静默失败。而这些坑跟你的业务逻辑一点关系都没有,不值得在第一次就硬碰。
第二,TypeScript 的类型系统对写这种工具型服务特别友好。入参用 zod 做运行时校验,AI 传过来的参数哪怕少了个字段,也能在到达你的业务函数之前就被拦截住,返回一个有意义的错误信息。这种健壮性在 AI 调用的场景里非常重要,因为大模型生成的参数经常不那么规矩。
第三,本地工具服务优先选 stdio(标准输入输出)传输,而不是 HTTP。对个人开发场景来说,stdio 意味着客户端直接起一个本地进程,用标准输入输出传协议消息,配置最简单、启动最快,也没有端口冲突、CORS、鉴权这些破事。等以后你要做成远程服务给多人用,再上 HTTP 传输也不迟。
我最后确定的依赖清单很克制:@modelcontextprotocol/sdk、zod、typescript、tsx。就这么几个,后面全部是实战代码。
3. 核心实现:亲手写好一个 MCP 服务
3.1 搭建项目骨架:项目初始化
先把项目目录建好,依赖装上。这一步没什么魔法,就是常规的 Node 工程初始化。
mkdir dev-helper-mcp cd dev-helper-mcp npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript tsx @types/node npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext这里我把 TypeScript 的模块配置成 NodeNext,是为了让@modelcontextprotocol/sdk的 ESM 导入正常工作。如果你用的是 CommonJS 风格的老项目,也可以不改,但新项目我更推荐直接 ESM。
接着在package.json里加三个脚本:
{ "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js" } }开发阶段用tsx直接跑 TypeScript 源码,不用等编译;交付时再用tsc编译到dist目录。这个区分在 vibe coding 的工作流里很关键,它能让你每天无数次"改代码-测试-再改代码"的循环变得飞快。
3.2 用 vibe coding 的方式让 AI 写核心代码:重点在任务规格
现在到了整篇文章的核心戏份:怎么用 vibe coding 的方式,把 MCP server 的核心代码写出来。
很多人的习惯是直接问 AI:"帮我写一个 MCP server。"这个问法太糙了,AI 只能给你一个最小示例。正确的做法是,在动手写代码之前,先花十分钟写一段足够具体的任务规格,然后把规格丢给 AI。
我自己常用的提示词模板大概是这样的,你可以直接照着改:
请用 TypeScript 和 @modelcontextprotocol/sdk 实现一个 MCP server。 服务名:dev-helper,版本号 1.0.0。 实现两个工具: 1. scan_todos 入参:dirPath(字符串,必填)、pattern(字符串,可选,默认 "TODO") 行为:递归扫描 dirPath 目录下的 .ts/.js/.tsx/.jsx/.py/.go/.md 文件, 找出内容中包含 pattern 的行。 返回:{ total: number, samples: [{ file, line, content }] } 2. git_status_summary 入参:repoPath(字符串,必填) 行为:在 repoPath 下执行 git status 和 git log -1。 返回:{ branch, modifiedCount, stagedCount, lastCommitMessage } 其它要求: - 使用 StdioServerTransport。 - 工具返回格式使用 MCP 的 content 数组,文本类型。 - 出错时返回 { isError: true, content: [...] }。 - 不要使用 console.log 输出业务信息。这段规格里我写清楚了每个工具的入参、行为、返回结构,还有协议层面的约束。AI 看到这种规格,不需要猜需求,它只需要把翻译工作干完。
你把这段提示词喂给任何一个主流 AI 编程工具,它大概率会给你一份跟下面结构差不多的代码(实际生成会根据你选的 SDK 版本略有差异,重点是理解结构):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { promises as fs } from "node:fs"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const server = new McpServer({ name: "dev-helper", version: "1.0.0", }); async function walk(dir: string): Promise<string[]> { const entries = await fs.readdir(dir, { withFileTypes: true }); const results: string[] = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; results.push(...(await walk(full))); } else { results.push(full); } } return results; } server.tool( "scan_todos", { dirPath: z.string(), pattern: z.string().optional().default("TODO"), }, async ({ dirPath, pattern }) => { const allowedExt = /\.(ts|js|tsx|jsx|py|go|md)$/; const files = (await walk(dirPath)).filter((f) => allowedExt.test(f)); const samples: Array<{ file: string; line: number; content: string }> = []; for (const file of files.slice(0, 300)) { const lines = (await fs.readFile(file, "utf8")).split("\n"); lines.forEach((lineText, idx) => { if (lineText.toLowerCase().includes(pattern.toLowerCase())) { samples.push({ file: path.relative(dirPath, file), line: idx + 1, content: lineText.trim(), }); } }); } return { content: [ { type: "text", text: JSON.stringify( { total: samples.length, samples: samples.slice(0, 50), }, null, 2 ), }, ], }; } ); server.tool( "git_status_summary", { repoPath: z.string(), }, async ({ repoPath }) => { try { const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { cwd: repoPath, }); const lines = stdout ? stdout.split("\n").filter(Boolean) : []; const staged = lines.filter((line) => /^[MADRC]/.test(line)).length; const modified = lines.filter((line) => /^.[MD]/.test(line)).length; const { stdout: logStdout } = await execFileAsync("git", ["log", "-1", "--pretty=%s"], { cwd: repoPath, }); return { content: [ { type: "text", text: JSON.stringify( { branch: "", stagedCount: staged, modifiedCount: modified, lastCommitMessage: logStdout.trim(), }, null, 2 ), }, ], }; } catch (err) { return { content: [ { type: "text", text: `git 命令执行失败:${(err as Error).message}`, }, ], isError: true, }; } } ); const transport = new StdioServerTransport(); await server.connect(transport);有没有发现,这份 AI 生成的代码已经能用,但有一个小问题:git_status_summary没有返回分支名。我在规格里明确写了要返回branch,它用一个空字符串糊弄过去了。这就是 vibe coding 最真实的一面:AI 生成的代码需要人工检查,你要带着"验收"的心态去读它,而不是"欣赏"的心态。
让 AI 接着把分支名补上也很简单,我直接把报错/缺失反馈给它:"git_status_summary 返回的 branch 是空字符串,请用 git branch --show-current 获取。"它一般一两轮就能改好。这种"规格明确—生成—检测—回灌"的循环,就是 vibe coding 的日常。
3.3 理解一下你刚写的协议握手过程
代码能跑,不代表你懂了。我建议你花两分钟理解一下程序启动后到底发生了什么,因为后面调试时你会天天跟这些概念打照面。
整个过程是这样的:
- 客户端启动
dev-helper进程,进程的标准输入输出被客户端接管。 - 客户端往标准输入写一条
initialize请求,包含协议版本、客户端信息、能力声明。 - 服务端回一条
initialize响应,带上自己支持的协议版本和服务端信息。 - 客户端再发一个
notifications/initialized通知,表示握手完成。 - 客户端发送
tools/list请求,拿到服务端所有的工具描述列表。 - 当模型决定调用某个工具时,客户端发送
tools/call请求,服务端执行函数,返回content数组。
上面这些消息都是 JSON-RPC 2.0 格式,一行一条,通过标准输入输出传输。官方 SDK 把 2 到 5 步全部封装好了,你真正要写的只有第 6 步——也就是每个工具的处理函数。
有一个极其容易踩的坑。因为协议走 stdio,进程的stdout(标准输出)是协议的专用通道,你如果在业务代码里用console.log打印调试信息,会把协议消息冲散,客户端直接解析失败,表现就是"工具列表加载不出来"或者"连接莫名其妙断开"。调试信息要写到stderr(标准错误),或者走 SDK 提供的日志通知机制。这个坑,我可以说十个新人里八个踩过。
3.4 本地测试:让 MCP 服务真正跑起来
写完代码,第一件事是编译:
npm run build编译通过后,你可以用 MCP 官方 Inspector 调试器来测试服务。它的作用相当于浏览器开发者工具,可以手动连上服务端、查看工具列表、调用工具、看请求响应全链路。
npx @modelcontextprotocol/inspector node dist/index.js启动 Inspector 后,在 UI 里点一下连接,你就能看到dev-helper的两个工具出现在列表里。调用scan_todos,让它扫描你当前这个项目目录,返回结果里应该能看到我们刚才写的那些 TODO 注释。
想更硬核地验证,也可以不用调试器,直接在终端里模拟客户端发一条tools/list请求:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | node dist/index.js你会在终端看到一长串 JSON 响应,里面包含serverInfo和工具列表。到这一步,说明你的 MCP 服务核心链路是通的。
最后,把这个服务注册到任意一个支持 MCP 的 AI 编程客户端里。配置文件长的都差不多,关键内容如下:
{ "mcpServers": { "dev-helper": { "command": "node", "args": ["/绝对路径/到/dev-helper-mcp/dist/index.js"] } } }配置好之后,你在 AI 的对话窗口问一句"帮我看看这个项目里还有多少没完成的 TODO",它就会自己去调用scan_todos,而不是编一个数字给你。这一刻你会真实感受到,AI 从"猜"变成了"查"。
4. 新手最容易踩的坑:定位问题与排查实录
4.1 一张速查表:常见的 MCP 服务问题
我把自己和朋友们反复踩过的坑整理了一下,做成一个速查表,你如果以后遇到类似现象,直接按表索骥。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 客户端连接后工具列表为空 | server 启动失败,或 stdout 被业务日志污染 | 先手动跑node dist/index.js看有没有报错;删除所有console.log |
| 调用工具时报"connection closed" | server 进程崩溃,或未捕获异常导致退出 | 在 handler 外层加 try/catch;用 stderr 打印错误 |
| 调用工具后模型说"没有这个工具" | 客户端缓存了旧的工具列表 | 改完代码后必须重启 AI 客户端,重新建立连接 |
| 返回结果显示未定义 | 工具返回格式不合规,少了content数组 | 检查返回值是否符合 MCP 协议格式 |
| 工具能跑但结果明显不对 | 路径参数被 AI 猜错了 | 在工具描述和语法里写清楚"传绝对路径",不要让它猜 |
| 扫描大项目时超时 | 遍历目录没有排除node_modules | 加忽略规则,限制文件数量,超出就返回摘要 |
| 错误信息被模型当成正常结果 | 失败时没有设置isError: true | 所有异常分支都返回isError: true |
这里面我特别想展开说两条。第一条,console.log污染 stdout 的问题,它的隐蔽性在于服务端本地跑的时候完全正常,只有通过客户端连接时才会出问题。你排查半天以为是协议写错了,结果就是把一个调试日志删了的事。建议从一开始就养成习惯:所有业务日志一律走console.error。
第二条,路径问题。AI 调工具时,如果工具描述里没写清楚路径怎么传,它很可能传一个相对路径,然后扫描结果为空。你在做"给 AI 用的工具"时,立场要站到 AI 那边:它能拿到什么信息、怎么表达最不容易出错。我的做法是在工具描述第一行就写"请使用绝对路径",并在入参的 zod 描述里再强调一遍。
4.2 三个让 vibe coding 效率翻倍的排查习惯
除了具体的错误,我更想分享三个工作习惯。它们不算技术,但直接影响你用 vibe coding 了一天之后的血压。
第一个习惯是"先人工,后 AI"。任何工具接入 AI 之前,先自己在命令行里手动跑一遍背后的逻辑。比如git_status_summary底层就是git status,你先在仓库里手动执行,确认输出符合预期,再让 AI 去调。如果 AI 调用失败,你就知道问题大概率出在协议封装或参数传递上,而不是业务逻辑本身。这个隔离排查法,能帮你省掉绝大多数的瞎折腾。
第二个习惯是"报错要整段回灌"。AI 在 vibe coding 过程中代码写错了,你千万不要自己看完报错之后,把错误"总结"成一句话再告诉它。直接把原始报错贴给它,让它自己读。因为你在转述的过程中,很容易丢失关键字段,比如某个库版本的兼容性提示。我自己试过很多次,把TypeError: xxx is not a function整段贴过去,AI 往往能一眼定位到是 API 版本变了。
第三个习惯是"一次只让它改一个点"。让 AI 同时改五个地方,它改到第三个的时候,通常已经把前两个改没了。正确做法是:一个循环只处理一个问题,验证通过再进下一个。你会发现,AI 的上下文窗口越小、任务越聚焦,代码质量越稳定。这也是 vibe coding 和传统编程思维最大的区别:它适合大量小步快跑的迭代,不适合一锅端的重构。
5. 进阶方向与我的实操心得
5.1 这个服务还能往哪走:从工具到资源、到远程服务
现在你已经有了一个能跑的 MCP 服务,接下来可以很自然地往几个方向延伸。
一个是加 resources。比如把项目的 README 或配置文件暴露成资源,AI 在对话时可以直接读取,不需要你再手动复制粘贴。这在"让 AI 快速理解项目"的场景里特别有用。
另一个是加 prompts。把"帮我 review 最近的代码改动"这种常用指令做成一个模板,AI 客户端里一键就能唤起,不用每次重新敲一遍。
再一个是传输层的升级。如果你希望这个服务部署在服务器上,给多个人用,就需要把传输方式从 stdio 改成 HTTP,加上鉴权信息。这个方向适合团队工具,可以等你的服务稳定了再考虑。
我个人觉得,MCP 的价值不在于协议本身多复杂,而在于它让 AI 工具生态有了一个统一的"插槽"。你今天给 AI 写的小工具,明天可能就跑到别的客户端里被别的模型调用,不需要重写。这种标准化的杠杆效应,才是它真正迷人的地方。
5.2 最后分享一点关于 vibe coding 的真话
写完这个服务再回头看,我最大的体会是:vibe coding 能不能用好的分水岭,不在模型强弱,而在你有没有给 AI 创造"可验证"的环境。MCP 恰好就是那个把真实世界接入对话的工具。
我踩过很多坑之后,现在的工作流已经很固定了:先用十分钟把任务规格写清楚,再让 AI 写代码;写完立刻编译,编译不过就把报错整段回灌;能跑通之后,再通过 MCP 工具让 AI 自己检查结果。整个循环里,我负责判断,AI 负责执行,大家各司其职。
如果你要亲手试的话,我的建议是从一个比"开发助手"更小的工具开始,比如只做一个扫描 TODO 的 MCP 服务。跑通之后,你会对整个链路有体感,再回头去看其它成熟的 MCP 服务,思路会清晰很多。最后帮你留个记忆点:MCP 服务的工具描述,一定用给同事看的接口文档的标准去写,因为你的下一个调用者,大概率是一个不太有耐心、但非常有执行力的 AI。