MCP Server Tool 开发学习文档:从零搭建可调试的本地工具服务

发布时间:2026/10/7 14:25:46
MCP Server Tool 开发学习文档:从零搭建可调试的本地工具服务
1. 从一次“工具调不通”说起MCP Server Tool 到底解决什么问题如果你最近在折腾 AI 应用大概率听过 MCPModel Context Protocol。简单说它是一套让模型和外部工具“说同一种语言”的协议。而MCP Server Tool 开发就是你自己写一个服务把某个能力抓网页、查数据库、跑脚本注册成标准工具让支持 MCP 的客户端能自动发现并调用它。它适合谁三类人最该上手一是想把内部系统接进 AI 助手的后端同学二是做智能硬件、需要本地工具链的嵌入式开发者三是想理解“工具注册—参数校验—调用链路”这条完整链路的 AI 应用开发者。你不需要先精通协议细节只要会写 Python 函数就能跑通第一个工具。我见过太多人卡在同一个地方工具写好了客户端却报Unknown tool或者参数校验失败日志里只有一行reading choices让人摸不着头脑。问题往往不在业务逻辑而在注册声明和实际调用对不上——list_tools里声明的inputSchema和call_tool里读取的arguments键名不一致或者传输方式选错导致请求根本没到服务端。这篇学习文档就按“能跟做”的标准来先给可复制的项目初始化配置再给工具定义模板然后本地调试最后通过统一 Key/API 通道做端到端验证。全程用 stdio 和 SSE 两种传输方式对照把踩坑点摊开讲。你跟着敲一遍基本就能独立开发自己的 MCP Server Tool 了。2. 前置准备项目初始化与 TaoToken 统一通道配置动手之前先把环境和“通道”理清楚。MCP Server Tool 本身是本地服务但你要验证它能不能被模型正确调用就需要一个能发起工具调用的客户端环境。这里我用 TaoToken 的统一 Key/API 通道来做验证好处是 Base URL 和 Key 一套配置通吃不用在多个平台之间来回切换。先说项目初始化。推荐用uv管理依赖速度快、隔离干净。新建目录后执行mkdir mcp-tool-demo cd mcp-tool-demo uv init --python 3.11 uv add mcp uvicorn starlette anyio httpx click如果你习惯 pip等价命令是pip install mcp uvicorn starlette anyio httpx click依赖说明一下mcp是官方 Python SDK提供Server、types、stdio_server、SseServerTransport等核心类starletteuvicorn用于 SSE 模式的 HTTP 服务anyio负责异步主循环httpx用来发外部请求click解析命令行参数。接下来配置 TaoToken 通道。访问控制台拿到 API Key然后在项目根目录建一个.env文件记得加进.gitignoreTAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api这里有个关键点Base URL 用https://taotoken.net/api不要带任何多余路径。很多 401 报错就是因为把/v1之类的后缀手动拼上去了导致鉴权路径不匹配。Key 的获取入口在控制台的 API Keys 页面模型对话入口可以用来做交互式验证。注意.env只放本地不要提交到仓库。团队协作时用环境变量注入别把 Key 硬编码进源码。环境就绪后目录结构建议这样组织后面调试会清晰很多mcp-tool-demo/ ├── .env ├── pyproject.toml ├── server.py # MCP Server 主文件 ├── client_test.py # 本地调用测试 └── tools/ └── fetch_tool.py # 工具业务逻辑把工具逻辑单独拆文件是为了后面工具变多时好维护。一个 Server 注册十几个工具很常见全塞一个文件会失控。3. 可复制配置工具定义模板与 Server 注册完整代码这一节是核心直接给能跑的完整代码。先看工具业务逻辑tools/fetch_tool.pyimport httpx from mcp import types async def fetch_website(url: str) - list[types.TextContent]: headers {User-Agent: MCP-Tool-Demo/1.0} async with httpx.AsyncClient(timeout15.0) as client: response await client.get(url, headersheaders) response.raise_for_status() return [types.TextContent(typetext, textresponse.text[:2000])]注意返回类型必须是list[types.TextContent | types.ImageContent | types.EmbeddedResource]这是协议规定的。截断到 2000 字符是防止超大页面把上下文撑爆实际项目里你可以按需调整。然后是server.py包含工具注册、参数校验和双传输模式import anyio import click import uvicorn from mcp import types from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.responses import Response from starlette.routing import Mount, Route from tools.fetch_tool import fetch_website app Server(mcp-tool-demo) app.list_tools() async def list_tools() - list[types.Tool]: return [ types.Tool( namefetch, description抓取指定网页并返回文本内容, inputSchema{ type: object, required: [url], properties: { url: { type: string, description: 要抓取的网页 URL, } }, }, ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name ! fetch: raise ValueError(fUnknown tool: {name}) if url not in arguments: raise ValueError(Missing required argument url) return await fetch_website(arguments[url])这里有两个必须对齐的地方list_tools里namefetchcall_tool里判断的也是fetchinputSchema里required: [url]call_tool里读的也是arguments[url]。任何一处不一致客户端就会报工具不存在或参数缺失这是最高频的坑。接着是入口函数用 click 控制传输方式click.command() click.option(--port, default8000, helpSSE 监听端口) click.option(--transport, typeclick.Choice([stdio, sse]), defaultstdio) def main(port: int, transport: str) - int: if transport stdio: anyio.run(run_stdio) else: run_sse(port) return 0 async def run_stdio(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) def run_sse(port: int): sse SseServerTransport(/messages/) async def handle_sse(request): async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: await app.run(streams[0], streams[1], app.create_initialization_options()) return Response() starlette_app Starlette( debugTrue, routes[ Route(/sse, endpointhandle_sse, methods[GET]), Mount(/messages/, appsse.handle_post_message), ], ) uvicorn.run(starlette_app, host0.0.0.0, portport) if __name__ __main__: main()如果你用的是 Claude Code 或 Cline 这类客户端它们的 MCP 配置通常是一个 JSON 片段三件套必须写全——Base URL、Key、Model ID。以 Cline 的 MCP 配置为例{ mcpServers: { tool-demo: { command: uv, args: [run, python, server.py, --transport, stdio], env: { TAOTOKEN_API_KEY: sk-你的实际Key, TAOTOKEN_BASE_URL: https://taotoken.net/api } } } }stdio 模式下客户端会自己拉起进程所以command和args要指向你的启动命令。SSE 模式则改成填 URLhttp://localhost:8000/sse。两种模式别混用stdio 配置里填 URL、SSE 配置里写 command都会导致连接失败。4. 本地调试与端到端验证从 list_tools 到 call_tool 跑通代码写完先别急着接客户端用本地测试脚本把链路跑通最稳妥。新建client_test.pyimport asyncio from mcp.client.session import ClientSession from mcp.client.sse import sse_client, SseServerParameters async def main(): params SseServerParameters(urlhttp://localhost:8000/sse) async with sse_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() print(可用工具:, [t.name for t in tools.tools]) result await session.call_tool(fetch, {url: https://example.com}) print(调用结果:, result.content[0].text[:200]) asyncio.run(main())先启动 SSE 服务uv run python server.py --transport sse --port 8000看到 uvicorn 输出Uvicorn running on http://0.0.0.0:8000就说明服务起来了。另开一个终端跑测试脚本uv run python client_test.py预期输出类似可用工具: [fetch] 调用结果: !doctype htmlhtml...list_tools返回工具名列表说明注册声明被正确读取call_tool返回网页内容说明参数校验和业务逻辑都通了。这两步都过本地链路就没问题。接下来做端到端验证把 TaoToken 通道接进来。如果你用的是支持 MCP 的编码客户端在配置里填好三件套后直接问模型“帮我抓取 example.com 的内容”。模型会先调用list_tools发现fetch工具再发起call_tool。你可以在服务端日志里看到完整的调用记录。验证模型对话能力时可以用模型对话入口做一次交互式确认确保 Key 和 Base URL 生效。长期做编码和 Agent 开发的建议走 Coding Plan配额和稳定性更适合持续调试。实测下来端到端最容易出问题的不是代码而是客户端配置里的路径和传输方式。stdio 模式下客户端拉起的进程工作目录可能不是你的项目根目录导致tools.fetch_tool导入失败。解决办法是在配置里显式指定cwd或者把工具逻辑内联进server.py。SSE 模式则要确认端口没被占用/sse和/messages/两个路由都要能访问。5. 常见报错排查401、Unknown tool、reading choices 逐个击破调试阶段报错是常态关键是看懂错误在说什么。下面这几个是我踩过最多的对照着排查能省不少时间。401 Unauthorized九成是 Key 或 Base URL 的问题。先确认.env里的TAOTOKEN_API_KEY没有多余空格或引号再确认 Base URL 是https://taotoken.net/api没有手动拼/v1。如果客户端配置里同时写了环境变量和硬编码以硬编码为准容易覆盖出错。排查方法用 curl 直接打一次接口看返回是不是 401。Unknown tool: xxxcall_tool里判断的工具名和list_tools里声明的对不上。检查两处name字段是否完全一致大小写敏感。还有一种情况是客户端缓存了旧的工具列表重启客户端即可。Missing required argument urlinputSchema里声明了required: [url]但客户端传参时键名写成了URL或link。JSON Schema 的键名是大小写敏感的。另外确认call_tool里读的是arguments[url]而不是arguments.get(url)后没做空判断。local proxy failed / reading choices这类错误通常出现在 SSE 模式下客户端连不上/sse端点。先确认服务真的在监听curl http://localhost:8000/sse应该返回一个持续的事件流而不是 404。如果返回 404检查 Starlette 路由注册顺序Route(/sse)要在Mount(/messages/)之前。reading choices有时是客户端解析 SSE 事件格式失败确认SseServerTransport的路径参数和客户端 URL 完全匹配。OAuth 相关报错如果你在客户端里配了 OAuth 流程但服务端没实现会卡在授权环节。本地调试阶段建议先用 API Key 直连别引入 OAuth。等工具稳定了再考虑加鉴权层。导入错误 ModuleNotFoundErrorstdio 模式下客户端拉起进程时工作目录不对。在 MCP 配置里加cwd: /你的项目绝对路径或者用uv run --directory /你的项目路径 python server.py。排查有个通用思路先确认服务端单独能跑再确认客户端能连上最后确认工具能被调用。三层分开验证比一上来就端到端调试高效得多。服务端日志一定要开app.run的调用记录会告诉你请求到底有没有到。6. 把工具接进真实工作流下一步怎么走跑通第一个工具后你会发现 MCP Server Tool 的开发模式很统一写业务函数、声明 schema、注册、选传输方式。真正拉开差距的是工具设计的颗粒度和错误处理。给你几个实用建议。第一工具粒度别太细也别太粗。一个工具只做一件事但要把这件事做完整。比如“抓网页”就专注抓取和返回别在里面顺便做摘要摘要交给模型。第二inputSchema的description写清楚模型靠它决定什么时候调用你的工具。描述模糊的工具模型要么不用要么乱用。第三错误信息要具体。raise ValueError(Missing required argument url)比raise ValueError(bad input)有用得多客户端能把具体原因反馈给模型模型可以自我修正后重试。传输方式的选择也有讲究。本地开发和嵌入式场景用 stdio简单、无端口冲突需要多客户端共享或 Web 场景用 SSE。生产环境如果工具调用量大SSE 模式要加连接池和超时控制别让一个慢请求拖垮整个服务。验证环节建议把模型对话、Coding Plan、API Keys 这几个入口都走一遍确认你的 Key 在不同场景下都生效。接入文档里有各客户端的详细配置示例遇到配置问题先查文档再动手改。最后说个心态问题MCP 生态还在快速演进SDK 的 API 偶尔会变。遇到方法签名对不上先看官方 SDK 的 examples 目录比搜博客靠谱。把第一个工具跑通后面加工具就是复制模板改业务逻辑的事。真正的门槛不在写代码而在想清楚“这个能力该不该做成工具、怎么描述才能让模型用对”。想明白这点你的 MCP Server Tool 才算开发到位。