
从零开始写一个MCP Server我用一顿晚饭的时间让Agent学会了调我的本地工具最近“MCP”这个词在开发者圈子里刷屏的频率说实话已经高到让人有点麻木了。但真正让我下定决心动手去写的是一次特别尴尬的对话——我想让AI Agent帮我算一下“从今天开始15个工作日之后的日期”结果它义正言辞地告诉我“我无法访问当前日期和日历建议您手动计算”。那一刻我意识到模型的智力再高没有工具接口它就是一座孤岛。MCPModel Context Protocol就是用来打破这座孤岛的那座桥。这篇内容不是给资深架构师看的理论文章而是给“听说过MCP、跟过几个demo但自己还没动手写过”的普通开发者准备的实战笔记。我会从零开始用Python和TypeScript两个方向讲清楚怎么自己写一个MCP Server再接入Claude Desktop、Cursor这类Agent客户端让AI真正拥有“自己调工具”的能力。适合前端、后端、全栈甚至只写过脚本的入门开发者。1. MCP不是魔法它只是给Agent装了一个“标准插座”1.1 没有MCP之前Agent是怎么调工具的在MCP出现之前想让一个AI应用调用外部工具主流做法有三类第一类是直接在Prompt里给模型写“你现在可以调用以下函数”然后靠模型输出JSON格式的调用请求代码里再手动解析第二类是每个Agent框架自己封装一套工具注册机制比如LangChain的tool装饰器、OpenAI的function calling第三类就是最原始的——开发时把所有功能硬编码到main函数里Agent根本不存在动态选择。这三种方式的问题非常明显。函数调用协议不统一换一个模型厂商或换一个Agent框架工具代码基本要重写工具和数据源没办法跨应用复用你在LangChain里写好的工具搬到Claude Desktop里就是一张废纸最难受的是每个工具的参数校验、错误处理、结果序列化这些“管道活”都要自己做占用了大量本该花在业务逻辑上的时间。1.2 MCP在协议栈里的位置MCPModel Context Protocol模型上下文协议是Anthropic在2024年底开源的一个开放协议目标非常明确给“AI应用”和“外部工具/数据源”之间定义一个标准通信方式。你可以把MCP理解成一个“USB-C接口”标准——过去每个设备都有自己的充电口OpenAI一种插口、Claude一种插口、本地模型又一种插口现在大家商量好统一用USB-CMCP任何支持这个标准的Host宿主都能插上任何支持这个标准的Server工具端。整个MCP体系里有三个角色MCP Host运行AI模型和交互界面的程序比如Claude Desktop、Cursor、Cline这类客户端。MCP ClientHost内部负责与Server建连、收发消息的组件对普通开发者来说通常不用自己实现Host已经内置。MCP Server一个独立的小程序暴露出一组工具Tools、资源Resources或提示词Prompts通过MCP协议提供给Host调用。对小白来说你只需要记住你要写的是MCP Server一个独立运行的轻量服务可以是本地stdio进程也可以是远程HTTP服务。1.3 三种原语别一上来就全学MCP协议定义了三种核心原语很多人第一次看文档会被绕晕我用大白话翻译一下Tools工具让Agent“做事情”的入口比如“查询天气”“执行Shell命令”“搜索数据库”。工具是Agent主动调用的模型根据用户问题和工具描述决定“现在该用哪个工具”。Resources资源让Agent“读资料”的入口比如“读取某个文件内容”“查询某份文档”类似给Agent开放的只读数据接口。Prompts提示词预先写好的可复用Prompt模板帮助用户在特定场景下引导模型这个对小白来说可以最后再看。初学阶段90%的需求都用Tools就能覆盖。先把Tools写明白后面再碰资源和提示词完全不迟。1.4 传输方式stdio优先HTTP别急MCP的传输层有两种主流模式一种是stdioServer作为本地子进程启动通过标准输入输出和Host通信这是本地开发最常用的方式配置简单、延迟低另一种是Streamable HTTPServer跑成一个HTTP服务通过SSEServer-Sent Events或POST请求通信适合部署在远程服务器上。我的建议非常直接第一次动手请无脑选择stdio模式。原因很简单——没有网络权限问题、没有跨域问题、没有认证问题你把进程跑起来Host自然会通过管道和它对话。等完全跑通了再升级到HTTP模式做远程部署。2. 环境准备别在这一步卡住超过10分钟2.1 选Python还是TypeScript我给你的决策参考MCP官方SDK目前维护得比较成熟的有Python和TypeScript也有一部分社区SDK选哪个完全看你自己的技术栈。如果你平时写脚本类工具、数据处理类工具比较多或者想快速验证一个想法强烈建议Python原因后面代码部分你会感受到如果你本来就是Node.js生态的或者你的工具要深度嵌入前端工作流那TypeScript更顺手。我见过不少新手死在“纠结语言”上其实真的不用纠结——MCP的核心是协议不是语言。用你最有把握的语言把第一个Server跑通比选一个“理论上更优”但自己不熟的语言重要一万倍。Python环境推荐用uv来管理这是目前体验最好的Python包管理器快而且干净。Windows上可以用pip安装macOS/Linux用官方脚本。确保Python版本在3.10以上太低的话部分SDK特性用不了。# 用uv创建项目比手工python -m venv省事很多 uv init mcp-demo cd mcp-demo # 安装MCP官方Python SDK uv add mcp[cli]安装完成后验证一下版本uv run python -c import mcp; print(mcp.__version__)对Node.js生态的开发者环境准备对应的就是npm create -y mcp-demo cd mcp-demo npm install modelcontextprotocol/sdk2.2 理解MCP Server的最小运行骨架不管用什么语言一个MCP Server的代码骨架都遵循同样的逻辑创建一个Server实例或FastMCP实例注册一个或多个Tool函数每个函数有名称、描述、参数Schema用JSON Schema描述函数内部实现业务逻辑返回结果给AI启动Server让它监听stdio这个骨架看起来很简单但里面藏着一个重要的认知转变你的函数不是给“人”直接调用的而是给“模型”决定调用、再由Host去执行的。所以函数名字和描述写得清不清楚直接决定了模型在需要这个功能时会不会正确选中它。这个点很多人写起来才发现很重要我后面会专门讲。3. 第一个MCP Server只花5分钟跑通的完整代码3.1 Python版FastMCP装饰器三行搞定用MCP官方SDK里的FastMCP API写工具极其舒服。下面这段代码就是一个完整的MCP Server提供两个工具一个返回当前时间一个计算两个日期之间相差几天。from mcp.server.fastmcp import FastMCP from datetime import datetime # 创建MCP Server实例起个名字 mcp FastMCP(TimeTools) mcp.tool() def get_current_time() - str: 获取当前的本地时间格式为 YYYY-MM-DD HH:MM:SS return datetime.now().strftime(%Y-%m-%d %H:%M:%S) mcp.tool() def days_between(date1: str, date2: str) - int: 计算两个日期之间相差的天数。 参数格式: YYYY-MM-DD d1 datetime.strptime(date1, %Y-%m-%d) d2 datetime.strptime(date2, %Y-%m-%d) return abs((d2 - d1).days) if __name__ __main__: mcp.run()保存为server.py然后运行uv run python server.py你会看到进程启动后没有任何输出就那样挂着——别慌这说明它正在通过stdio监听来自Host的消息。你现在可以用MCP官方自带的调试工具来“模拟Host”验证它uvx mcp-inspector uv run python server.py命令跑起来后会启动一个本地Web调试面板你可以在里面看到一个协议交互界面手动调用get_current_time试试能看到返回结果。3.2 这个代码里藏了哪些关键点虽然代码只有十几行但有四个细节决定了这个Server“好不好用”第一函数注释docstring就是给模型看的说明书。MCP的FastMCP会自动把函数的docstring和类型注解转成JSON Schema作为工具描述发送给模型。模型决定“要不要调用这个工具”靠的就是这段描述。所以请不要写“返回日期差”这种废话要写清楚“在什么场景下用、参数是什么格式、返回值代表什么”。第二类型注解不只是给人看的也是给模型看的。date1: str这个注解会转成Schema里的type: string如果模型决定调用它就会想办法把用户话里的日期解析成字符串传进来。如果你写date1: datetimeSDK虽然也能转但序列化规则会更复杂反而容易出问题。小白阶段尽量用基础类型str、int、float、bool不要用自定义对象。第三mcp.run()默认就是stdio模式。如果要用HTTP模式需要传transportstreamable-http参数还需要额外指定Host和Port。先不要碰。第四主进程必须保持存活。我发现很多第一次跑MCP Server的人运行后看到“没反应”就CtrlC杀掉了——其实它是在等你连。记住stdio模式的Server就是要常驻进程Host启动它、连上它、互相对话。3.3 TypeScript版两份代码对照着看如果你选的是TypeScript完整的最小Server长这样import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; const server new McpServer({ name: TimeTools, version: 1.0.0, }); server.tool( get_current_time, 获取当前的本地时间格式为 YYYY-MM-DD HH:MM:SS, {}, async () { const now new Date(); const formatted now.toISOString().replace(T, ).slice(0, 19); return { content: [{ type: text, text: formatted }] }; } ); server.tool( days_between, 计算两个日期之间相差的天数, { date1: { type: string, description: 第一个日期格式 YYYY-MM-DD }, date2: { type: string, description: 第二个日期格式 YYYY-MM-DD }, }, async ({ date1, date2 }) { const d1 new Date(date1); const d2 new Date(date2); const diff Math.abs(d2.getTime() - d1.getTime()); const days Math.floor(diff / (1000 * 60 * 60 * 24)); return { content: [{ type: text, text: String(days) }] }; } ); const transport new StdioServerTransport(); await server.connect(transport);注意几个不同点TypeScript版需要自己声明参数的JSON Schemadate1: { type: string }而Python版靠类型注解自动生成TypeScript的返回结果必须拼成content数组结构Python版直接返回字符串SDK会帮你包好。两种语言都遵循同一套协议Host那边感知不到语言差异。4. 把Server接入Agent客户端在Claude Desktop里看到工具被自动调用4.1 用Claude Desktop演示一次完整调用链路写好的Server不接入客户端就只是一个空转的进程。最直观的演示方式是接入Claude Desktop也可以接Cursor、Cline、Zed等。打开Claude Desktop的配置文件macOS路径是~/Library/Application Support/Claude/claude_desktop_config.jsonWindows是%APPDATA%\Claude\claude_desktop_config.json在里面添加{ mcpServers: { TimeTools: { command: uv, args: [run, --directory, /绝对路径/你的项目目录, python, server.py] } } }保存后完全退出并重新启动Claude Desktop看到界面右下角有一个插头图标点开就能看到get_current_time和days_between两个工具已经注册成功。现在直接在对话框里问一句“今天是几号这个月的最后一天和它相差几天”——Claude会自己决定调用get_current_time先拿到今天日期再调用days_between计算两个日期的差。整个过程你不需要写一行“if 用户问了日期就调用XX”的逻辑完全是模型自主决策。4.2 配置里最容易踩的三个坑我在这个步骤上花过不少时间总结三个最典型的坑第一个坑路径写错。配置文件里的args数组每一项都是独立字符串。如果目录里有空格也必须放在同一个字符串元素里不要自己拼接命令。另外--directory参数如果你用了alias比如~有些环境不会展开最好写绝对路径。第二个坑uv命令不在宿主环境的PATH里。Claude Desktop是GUI应用它启动子进程时的PATH和你终端里的PATH不一定一致。如果你在终端里能跑uv但Claude Desktop连不上改成用uv的绝对路径比如/Users/你的用户名/.local/bin/uv或者直接改用python命令command: /usr/bin/python3, args: [/路径/server.py]。第三个坑改了代码不生效。MCP Server是在Claude Desktop启动时被拉起的你改了Server代码必须把Claude Desktop完全退出不是关窗口是彻底退出再重开否则它还连在旧进程上。排查时候最烦的就是这个服务端改了半天没反应其实是Host没重启。4.3 其他客户端配置的差异说明换了客户端配置方式九成相似差异只在配置文件路径和界面上。Cursor是在Settings里搜“MCP”可以直接添加命令Cline在App的MCP Marketplace里也能配置格式都是同一个工具名、启动命令、参数列表。原理没有任何区别我强烈建议你只用一个客户端跑通一个Server不要同时开多个减少变量。5. 进阶实战写一个联网查询MCP Server让Agent拥有“实时信息感知”5.1 模型不知道实时数据这是工具最大的价值点前面写的时间工具只用了本地系统功能还不够“性感”。真正让MCP大放异彩的场景是模型训练截止日期之前的信息它不知道但工具可以实时获取。比如天气、汇率、新闻、股票这些都可以通过MCP工具注入给模型。网上已经有很多“免费联网MCP”的现成方案比如Playwright MCP支持浏览器自动化Figma MCP可以读取设计稿信息蓝湖有蓝湖MCP甚至Matlab、IDA Pro都有对应的MCP服务。但其实自己写一个也不难而且能根据需求定制。下面这个例子我写一个调用公开天气API的Server用httpx发送请求。这里我选了无需API Key的公开接口例如wttr.in尽量减少环境配置成本你换成任何带鉴权的真实服务也一样。5.2 完整代码一个带超时和错误处理的天气工具import httpx from mcp.server.fastmcp import FastMCP mcp FastMCP(WeatherTools) mcp.tool() def get_weather(city: str) - str: 查询一个城市的当前天气情况。 参数城市用中文或拼音均可例如 北京 或 beijing。 返回内容包括天气现象、温度、湿度、风速和体感温度。 try: resp httpx.get( fhttps://wttr.in/{city}?formatj1langzh, timeout10.0, headers{User-Agent: MCP-WeatherDemo/1.0} ) resp.raise_for_status() data resp.json() current data[current_condition][0] lines [ f城市: {city}, f天气: {current[lang_zh][0][value]}, f温度: {current[temp_C]}°C (体感 {current[FeelsLikeC]}°C), f湿度: {current[humidity]}%, f风速: {current[windspeedKmph]} km/h, ] return \n.join(lines) except httpx.TimeoutException: return 查询超时请稍后再试。 except Exception as e: return f查询失败: {str(e)}这个设计里有一个非常重要的原则工具函数不能抛异常要返回描述性错误信息。原因在于Agent调用工具后只能拿到返回值文本如果工具抛出未捕获异常Host那边会显示调用失败模型拿到的信息很少就无法给出有意义的回复。把错误类型和原因写进返回文本里模型看到后就能判断下一步是换一种参数重试、还是向用户解释。5.3 为什么要专门强调“工具描述”这块奶酪很多人第一次写工具时会把精力全放在参数校验和逻辑实现上但我必须掏心窝子说一句对MCP工具docstring描述比实现代码更能决定工具的实际效果。我做过对比实验。同一个天气查询函数第一种写法docstring是“Get weather”第二种写法是“查询指定城市的当前实时天气。参数city既可以传中文城市名也可以传拼音例如北京或beijing。这个工具适合回答任何关于‘某地现在热不热/下不下雨/适不适合出门’等问题。”——结果是第一种写法在Agent自主选择时经常被忽略第二种写法几乎百发百中。背后的逻辑是模型选择工具时会把它看到的所有工具描述和用户问题作语义匹配。描述越具体、包含的触发词越多、写得越像“这个工具为这类问题而生”模型选中它的概率就越高。这已经成了这个领域一条不成文的“潜规则”。给自己写的每一个工具都像一个相亲简介一样认真写描述回报率极高。5.4 从本地到远程Streamable HTTP模式部署本地stdio模式跑通后MCP Server要部署到服务器上供远程访问就要用HTTP模式。改动其实非常小if __name__ __main__: # 本地调试用 stdio # mcp.run() # 远程部署用 HTTP mcp.run(transportstreamable-http)再配合一个简单的Web服务器用uvicorn之类的ASGI服务器就能把MCP Server暴露成一个HTTP端点。远程部署的核心难点已经不是代码本身而是鉴权和网络安全——没有鉴权的MCP Server就是一个裸奔的新鲜肉谁拿到你的地址谁就能调用你的工具这会非常危险。所以如果只是个人学习优先用本地stdio真要生产部署至少要加API Key认证和TLS。6. 踩坑实录我花了两个小时解决的MCP连接问题这里给你一份排查清单6.1 “Client closed connection” / 进程秒退不管你用Claude Desktop还是Cline最常遇到的就是“连接已关闭”或“进程启动后立即退出”。遇到这个问题第一件事不是去改协议代码而是先手动在终端里跑一次启动命令看有没有报错。比如配置里写的是uv run python server.py你就手动在项目目录跑一遍如果报Python版本错误、某个依赖没装、或者说Server地址被占用都会直接显示在终端里。如果手动跑没问题但Host连不上再看Host进程的PATH是否包含命令路径。GUI应用经常拿不到你~/.bashrc里设置的环境变量这时候改用绝对路径是最省事的。6.2 工具注册成功但调用时超时工具注册成功说明连接是通的但调用时超时多半是工具函数内部的问题——最常见的是网络请求卡住、内部有阻塞的同步操作、或者函数内部抛了异常但SDK那边没拿到返回值。我的排查习惯是在工具函数第一行加一个print(tool called, flushTrue)在程序里设置logging.basicConfig(levellogging.DEBUG)看日志输出停在哪一步基本能定位。还有一个隐蔽问题stdout被污染。stdio模式协议数据也是走stdout如果你在工具函数里用了print(log)而不是写到stderr就会把MCP协议的数据流污染掉导致Host解析失败。这一点一定要记住日志用logging、打到stderr不要用print打到stdout。6.3 参数类型对不上工具被模型“畸形调用”模型从用户自然语言里提取参数时偶尔会把应该传字符串的日期参数传成数字格式或者把多个城市塞进一个字段。这是JSON Schema和模型行为共同作用的结果。解法有三个方向参数描述里写清晰示例函数内部做容错解析比如先str()包裹一次最后兜底干脆把入口参数设计成一个能容纳多种输入的简单字符串解析逻辑放函数内部。对小白来说第三种方式最省心——入口越简单模型越不容易出错。6.4 不同Agent客户端的“审批机制”差异现在不少Agent客户端在调用工具前会做一次人工确认或者有自动审批开关比如Codex、Cursor里都有类似的机制。如果你看到“工具调用审批失败”之类的提示先检查客户端的安全策略设置比如“自动批准来自MCP Server的工具调用”这通常默认是关闭的。对本地可信的MCP Server打开自动审批属于合理的信任决策对来源不明的远程MCP建议保持人工确认。6.5 一个适合复制到笔记里的排查链路我把自己踩坑后沉淀下来的排查顺序写在这里按顺序走一遍80%的问题都能解决手动在终端执行启动命令确认Server能独立运行。用MCP Inspectoruvx mcp-inspector模拟Host调用工具看协议层是否通。检查配置文件命令路径、参数列表、目录是否正确。把Host完全退出重启确认不是缓存旧进程。改工具代码后看日志确认新代码是否真的生效。查stderr日志不要只看stdout。7. 从“把工具跑通”到“把工具写好”几条经验之谈写完了第一个能用的MCP Server后我再分享一点在设计和打磨上的体会这些是从“能跑”到“好用”之间的分水岭。工具粒度要“一工具一职责”不要搞一个万能函数。很多人图省事写一个execute_query函数把SQL、API、文件操作全塞进去。这样做Agent调用起来会很困惑描述也写不精准而且安全边界模糊。宁可多注册几个小工具每个函数只做一件事模型的选择和你的维护都会轻松很多。返回结果要结构化但不冗长。模型需要从返回文本中提取信息纯文本段落不如“键值对/列表”这种易读格式。但同时不要让返回体超过模型上下文承载范围如果数据量大返回摘要让Agent告诉用户“数据已获取共xx条可进一步查询详情”。工具要有可观测性。正式使用时给Server加日志、加调用计数、加耗时记录是非常大的加分项。你可以在代码里加一个简单的装饰器把每次工具调用的参数、结果长度、耗时统一记录到本地日志文件这样调试和性能分析都有数据支撑。扯回标题说的“解锁Agent工具调用技能”其实真正解锁的不只是会写代码而是理解了模型与外部世界协作的边界在哪儿。我个人的建议是用一顿晚饭的时间照着上面的代码把第一个Server跑起来再把它接入你日常在用的客户端里实际用一次那种“AI自己把事办了”的体验会远比看十篇教程更能建立直觉。下一步你想深入的话可以试着写一个操作本地文件的小工具、接一个你公司内部API的查询工具或者把Server部署到一台远程服务器上试试。路已经铺好了剩下的就靠你往前走了。