1. 为什么我要自己写一个 MCP 协议服务端
MCP 协议,全称 Model Context Protocol,是让大模型通过标准 JSON-RPC 接口调用外部工具的一套约定。你可以把它理解成「模型世界的 USB-C 接口」:不管背后是本地脚本、远程 HTTP 服务还是数据库查询,只要按 MCP 规范暴露能力,任何支持 MCP 的客户端都能直接调用。它适合谁?适合想把内部工具、脚本、业务系统接进 AI 工作流的开发者,也适合想搞懂「模型怎么知道该调哪个工具」这个黑盒的工程师。
我最早接触 MCP 是 2024 年,当时第一反应是「这不就是 function calling 换了个壳」。真正动手写服务端之后才发现,MCP 把能力协商、工具注册、消息结构都标准化了,客户端和服务端可以完全解耦。但问题也随之而来:本地调试时,每个 MCP 服务端都要单独配一套模型 Key,客户端配置里散落着各种 Base URL 和密钥,换一个模型就要改一遍配置。这篇实录就围绕「从零实现一个最小可用 MCP 服务端」展开,同时用 TaoToken 统一 Key 把模型接入这一层收敛掉,让你把精力放在协议实现本身。
整篇会按真实开发顺序走:先讲清楚 MCP 服务端的核心结构,再给出可复制的 config.toml 骨架和 TaoToken 配置片段,然后完整跑一遍 initialize → tools/list → tools/call 三个动作,最后把我在调试中踩到的报错逐个拆开。你跟着做,能拿到一个能算加减乘除、能被客户端识别、能真实返回结果的 MCP 服务端。
2. MCP 服务端核心结构与 TaoToken 统一 Key 前置
2.1 JSON-RPC 消息结构长什么样
MCP 底层走的是 JSON-RPC 2.0。所有交互都是「请求-响应」成对出现,请求体固定包含四个字段:jsonrpc、id、method、params。响应体则根据成功或失败,返回 result 或 error。我先把最小请求和响应贴出来,你对照着看就明白协议层其实很薄。
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "my-client", "version": "0.1.0" } } }服务端收到后要回一个结构对称的响应:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "calc-server", "version": "0.1.0" } } }这里有个容易忽略的点:id 必须原样回传,客户端靠它把响应和请求配对。如果你用异步框架,记得把 id 存进上下文,别用自增变量覆盖。
2.2 能力协商与工具注册流程
initialize 阶段本质是双方「亮底牌」。客户端告诉服务端自己支持哪些能力,服务端回告自己提供哪些能力。MCP 目前主要的能力域有 tools、resources、prompts 三类,我们做工具调用场景,只需要在 capabilities 里声明"tools": {}。
工具注册不是单独一个 RPC 方法,而是通过tools/list返回一个工具数组。每个工具对象包含 name、description、inputSchema 三个关键字段。inputSchema 用的是 JSON Schema 草案,客户端会把它转成模型能理解的函数签名。我实测下来,description 写得越具体,模型选工具的准确率越高,别偷懒写「计算两个数」。
2.3 TaoToken 统一 Key 的前置准备
MCP 服务端本身不直接调模型,但调试阶段我们往往需要一个能对话的客户端来验证工具是否被正确调用。这时候如果每个客户端都配一套模型 Key,配置会非常散。TaoToken 的作用是把模型接入收敛成一个统一入口:一个 Key、一个 Base URL,兼容 OpenAI 风格的接口。
你需要先去控制台创建一个 API Key,地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建后拿到形如sk-开头的密钥,Base URL 统一填https://taotoken.net/api。模型 ID 按你实际要用的填,比如gpt-4o-mini或claude-3-5-sonnet,具体以模型对话页 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 列出的为准。
注意:Base URL 后面不要手动加
/v1,SDK 会自己拼路径。我一开始多加了/v1,结果一直 404,排查了半小时。
3. 可复制的 config.toml 骨架与 TaoToken 配置片段
3.1 服务端 config.toml 骨架
我习惯把服务端配置和模型配置分开。服务端这边用 config.toml 描述工具元信息和运行参数,模型那边用环境变量或独立配置文件。先看服务端骨架:
[server] name = "calc-server" version = "0.1.0" protocol_version = "2024-11-05" transport = "stdio" [capabilities] tools = true resources = false prompts = false [[tools]] name = "add" description = "计算两个数字的和,输入 a 和 b 两个 number 类型参数" auto_approve = true [[tools]] name = "divide" description = "计算 a 除以 b 的结果,b 不能为 0" auto_approve = falsetransport 选 stdio 是最省事的,客户端直接拉起进程,通过标准输入输出收发 JSON-RPC。如果你要部署成远程服务,可以换成 SSE 或 streamable HTTP,但调试阶段 stdio 足够。
3.2 TaoToken 统一 Key 配置片段
模型接入这层,我用一个独立的 settings 片段管理。如果你用的是 Cline 或类似支持 MCP 的客户端,配置结构大致如下:
{ "mcpServers": { "calc-server": { "command": "python", "args": ["/Users/me/mcp-calc/server.py"], "env": { "TAOTOKEN_API_KEY": "sk-你的密钥", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_MODEL_ID": "gpt-4o-mini" }, "autoApprove": ["add"], "disabled": false } } }这里三件套必须写全:Base URL 填https://taotoken.net/api,Key 填控制台创建的密钥,Model ID 填你要用的模型。少任何一个,客户端在调用模型时都会报鉴权或模型不存在。如果你用的是 Codex 系的客户端,配置落在~/.codex/auth.json,结构类似,把 base_url 和 api_key 对应填进去即可。
提示:autoApprove 只对只读或低风险工具开,像 divide 这种可能触发除零的工具,我建议保持 false,让客户端每次弹确认。
3.3 服务端主循环的最小实现
配置有了,服务端主循环其实就是一个「读一行、解析、分发、写一行」的循环。用 Python 写大概是这样:
import sys import json TOOLS = { "add": lambda a, b: a + b, "subtract": lambda a, b: a - b, "multiply": lambda a, b: a * b, "divide": lambda a, b: a / b if b != 0 else (_ for _ in ()).throw(ValueError("division by zero")), } def handle(req): method = req.get("method") rid = req.get("id") if method == "initialize": return {"jsonrpc": "2.0", "id": rid, "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "calc-server", "version": "0.1.0"} }} if method == "tools/list": return {"jsonrpc": "2.0", "id": rid, "result": {"tools": [ {"name": n, "description": f"{n} 运算", "inputSchema": { "type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"] }} for n in TOOLS ]}} if method == "tools/call": params = req.get("params", {}) name = params.get("name") args = params.get("arguments", {}) try: value = TOOLS[name](args["a"], args["b"]) return {"jsonrpc": "2.0", "id": rid, "result": { "content": [{"type": "text", "text": str(value)}] }} except Exception as e: return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": str(e)}} return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}} for line in sys.stdin: line = line.strip() if not line: continue resp = handle(json.loads(line)) sys.stdout.write(json.dumps(resp) + "\n") sys.stdout.flush()这段代码没有任何第三方依赖,直接python server.py就能跑。注意 stdout 一定要 flush,否则客户端会一直等缓冲。
4. 验证请求:initialize → tools/list → tools/call 完整跑通
4.1 手动发 initialize
服务端起来后,先别急着接客户端,用管道手动喂三条消息,确认协议层没问题。把下面三行存成test.jsonl:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli","version":"0.1.0"}}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":3,"b":5}}}然后执行:
cat test.jsonl | python server.py你应该看到三行响应,第三行的 result.content[0].text 是8。如果这一步通了,说明 JSON-RPC 分发和工具执行都没问题。
4.2 接入客户端验证模型调用
手动验证通过后,把服务端配进客户端。以 Cline 为例,在 MCP 配置里填好 command、args 和 env 三件套,保存后客户端会重新拉起进程并自动发 initialize。你可以在客户端里问「3 加 5 等于几」,观察它是否触发了 add 工具。
我实测下来,模型能不能正确选工具,取决于两个因素:一是 tools/list 里 description 是否清晰,二是客户端拼给模型的系统提示词是否把工具列表完整塞进去了。如果模型直接回答「8」而没走工具,说明它没识别到工具,这时候把 description 改得更具体,比如「当用户要求做加法时调用此工具」。
4.3 用 TaoToken 模型对话页做交叉验证
如果你怀疑是模型侧的问题,可以单独去模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 发一条带工具描述的消息,看模型是否能输出符合规范的 JSON。这一步能帮你区分「是 MCP 服务端没返回工具」还是「是模型没按格式输出」。两者排查方向完全不同,别混在一起查。
5. 本篇常见报错排查
5.1 401 鉴权失败
报错长这样:{"error":{"code":401,"message":"invalid api key"}}。九成是 Key 填错或 Base URL 写错。先确认 Key 是控制台新建的、没有多余空格;再确认 Base URL 是https://taotoken.net/api,没有多加/v1或漏掉/api。如果用的是环境变量,检查客户端是否真的把 env 传给了子进程,有些客户端要求 env 写在 mcpServers 节点下而不是全局。
5.2 local proxy failed
这个报错通常出现在客户端启动服务端进程时。原因可能是 command 路径不对、Python 不在 PATH 里,或者 args 里的脚本路径是相对路径而客户端工作目录不对。解决办法是把 command 写成绝对路径,比如/usr/bin/python3,args 里的脚本也写绝对路径。我踩过的坑是 Windows 下用了python但系统只认py,换成py就好了。
5.3 reading choices 解析失败
这个报错来自模型响应解析层,说明模型返回的内容不是合法 JSON。常见原因是系统提示词里没强制「只输出 JSON」,模型夹带了自然语言。你可以在客户端侧检查拼给模型的 system message,确保有类似「ONLY respond with the exact JSON object」的约束。如果客户端不让你改提示词,那就换一个对工具调用支持更好的模型。
5.4 OAuth 相关报错
如果你接的是需要 OAuth 的远程 MCP 服务端,可能会遇到OAuth token expired或invalid_client。本地 stdio 模式一般不会碰到,但一旦你把 transport 换成 SSE 并加了鉴权,就要确保 token 刷新逻辑正确。调试阶段建议先用 stdio 跑通,再上远程。
5.5 工具被调用但结果为空
这种情况多半是 tools/call 的返回结构不对。MCP 规定 result.content 必须是数组,数组元素要有 type 和 text 字段。如果你直接返回{"result": 8},客户端解析不到内容,就会显示空。对照第 3.3 节的返回结构改一下即可。
6. 把统一 Key 用在长期编码与 Agent 场景
跑通最小服务端只是第一步。当你开始把 MCP 用在日常编码、Agent 编排或者多工具串联时,模型调用量会明显上升,这时候统一 Key 的价值就体现出来了:不用在每个客户端里重复配 Key,换模型只改一个 Model ID,计费和用量也能在一个地方看。
如果你打算把 MCP 服务端长期挂在开发流程里,比如让 Agent 自动调工具改代码、跑测试,建议直接上 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。它针对长时代码生成和工具调用做了额度优化,比按次调用更划算。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言 SDK 的完整示例,包括怎么把 Base URL 和 Key 注入到 MCP 客户端的 env 里。
最后留一个我自己的习惯:每次改完服务端,先用手动管道跑一遍三条消息,确认协议层没退化,再进客户端测模型调用。这样能把「协议 bug」和「模型行为」分开,排查效率高很多。