实操构建 MCP 服务器

9 分钟读完

C1
深入解析 · MCP

构建 MCP 服务器并非在 hello-world 之上简单加上工具——真正要紧的决定是每种能力应当做成 tool、resource 还是 prompt,以及一台中位数服务器实际长什么样。

每份 MCP 教程都教你注册两个工具、把字符串原样回显,然后宣告胜利;生产环境的服务器完全不是那样。流传中的中位数 MCP 服务器带着 5 个工具、0 个资源、0 个提示、以及无鉴权——一项 1,412 台服务器的调查把这些数字摆到了台面上——而这个形状体现的是多数教程从不提及的一组设计选择。tool、resource、prompt 三者之间的边界一错,下游的每个问题——令牌膨胀、脆弱的测试、看不懂的 agent 轨迹——都会顺着这条错线流下来。

STEP 1

你真正会用到的技术栈:FastMCP(Python)或 Standard Schema(TypeScript)。

2026 年上线的 Python MCP 服务器,几乎清一色用 FastMCPpip install fastmcp)搭建,而不是它当年封装的参考实现 mcp 包。FastMCP 是装饰器驱动的:你写一个普通函数,加上 @server.tool,库就从你的类型提示推导出 JSON Schema,替你协商参与者模型initialize 握手,并把 JSON-RPC 层完全藏起。Bloomberry 那份对 1,412 台生产服务器的调查发现,被采样的 Python 服务器中 FastMCP 的 SDK 份额最大——这也是为什么大量 2024 年的教程还在演示裸 Server 类和手动注册处理函数,那些教程已经在带偏读者:它们展示的正是 FastMCP 有意隐藏的那一层。

TypeScript 侧当前的 SDK 是 @modelcontextprotocol/sdk,它暴露一个使用 Standard Schema API 的 McpServer 类——你可以传入 Zod、Valibot 或 ArkType 的 schema,SDK 会把它适配成 MCP 的 inputSchema 形状,无需你手写一份 JSON Schema。截至 2026 年中,v2 处于 beta 阶段,但 v1 至少还会再被支持六个月,因此今天开工的项目短期内选哪一个都不会返工。更底层的 Python mcp 包依然存在,是你在需要直接掌控消息层——实现新型传输、封装非标准宿主、调试协议问题——时才伸手去拿的东西;用它来上线一台普通服务器是选错了海拔。

一台最小的 FastMCP 服务器实实在在很小:一个装饰器、一个函数、一次 run 调用。教程通常止步于此并把这当作"完成",但真正的决策才刚开始——你刚写下的那个函数签名,其实已经暗含了一整套"哪些东西应该放到服务器上"的选择。

STEP 2

三选一:tool、resource 还是 prompt。

MCP 给你三种原语来暴露一项能力,这个选择不是风格问题。Tools 是由模型主导、带副作用的动作:如果由模型在运行时决定什么时候调用,且调用会改变状态或代表模型执行某种查询,那它就是一个 tool。Resources 是由应用主导的上下文:URI 可寻址、只读的数据,宿主按自己的节奏读取并放入模型上下文——一份文件、一行数据库记录、一次在某个时点冻结的 API 响应。Prompts 是由用户主导的模板:服务器暴露的工作流脚手架,宿主把它呈现为斜杠命令或菜单条目,并用用户提供的参数展开。

能贴在便利贴上的助记:tool = 模型选、resource = 宿主选、prompt = 用户选。这就是全部判据。如果答案是"模型在运行时决定要不要拉取",那即便底层实现只是读一份文件,它也是 tool。如果答案是"宿主在模型还没看到这一轮之前就已经加载了它",那它是 resource。如果答案是"用户从菜单里挑一个",那它是 prompt。

最常见的失败模式是把一切都做成 tool,因为每份教程都在教 tool。Bloomberry 的调查发现,野生环境中的大多数服务器在 tool 上过度加码、在 resource 上使用不足;中位数服务器带的是 0 个资源、0 个提示。其中一部分是合理的——很多服务器封装的就是动作类 API,整个界面都带副作用——但另一部分就是作者从未问过这个问题。一个要求模型每次会话开头都记得调用的 "get_user_profile" tool,多半是一个伪装的 resource:宿主完全可以订阅一次,把 profile 放进上下文,模型也就不用为它花一次调用了。

翻成实操。如果"这东西什么时候被加载"的答案是"模型问的时候",写成 tool。如果答案是"总是加载,会话开头就加载",写成 resource。如果答案是"用户输入 /summarise 时",写成 prompt。反过来做,就等于让模型去做协议本来就替你安排好的事。

STEP 3

一台服务器实际上线的样子:中位数部署的形状。

Bloomberry 2026 年 2 月的调查——1,412 台公开 MCP 服务器,是目前公开数据里最大的一份——给了"真实服务器长什么样"以具体数字。中位数:5 个工具、0 个资源、0 个提示、无鉴权。不是"hello-world 再多几个";生产环境服务器就是会收敛到 5 这个数字。分布有长尾——有些服务器带 30 或 40 个工具——但中段就是小而重副作用的。

这个形状透露了 MCP 实际的使用方式。大多数服务器是在用一小组高价值操作去封装某个 API、CLI 或数据库;它们不是文档库或知识库——那才是 resource 更能发挥作用的场景。如果你的设计里有十五个工具,调查会提示你要么你的能力面确实异常宽,要么——更常见——你把它们拆得太细,用更粗粒度的工具或者干脆把服务器一分为二会更好。

数字暗示的另一种模式,从业者已经开始称之为"八台 MCP 生产栈":与其做一个覆盖整个业务域的大服务器,团队最后往往会落到几台各自专注一个系统的小服务器——文件系统服务器、数据库服务器、监控服务器、项目管理服务器等等。这既是设计决定也是分发决定,且会与宿主的工具选择预算产生交互:当宿主同时跑八台每台 5 个工具的服务器时,模型上下文里就已经有 40 个工具,可发现性问题开始咬人。通常的正解是让服务器保持窄小,让宿主居中调度——而不是不断把某一台喂胖到无所不能。

STEP 4

具体做法:注册一对 search-then-fetch 工具。

典型例子是内容服务器——文档、知识库、代码索引——上面的朴素设计是一个 search 工具,直接返回完整命中的文档。问题是上下文膨胀:一次调用就可能把数兆字节的散文倒进循环,且这一轮无法挽回。search-then-fetch 模式——在微软 Learn MCP 服务器的复盘中被点名——把这一个操作拆成两个工具:search 返回一串 {id, summary}fetch 接受一个 id、只返回单个文档的完整内容。两次往返代替一次,但每次都很小,且模型自己挑哪几篇值得完整读。

# server.py — FastMCP search-then-fetch pair
from fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP("docs-server")

class Hit(BaseModel):
    id: str
    title: str
    summary: str

@mcp.tool
def search(query: str, limit: int = 10) -> list[Hit]:
    """Search docs for the top matches. Returns id + summary only;
    call fetch(id) for the full document body."""
    return [Hit(id=r.id, title=r.title, summary=r.snippet)
            for r in index.search(query, k=limit)]

@mcp.tool
def fetch(id: str) -> str:
    """Return the full body of one document by id.
    Use after search() to pull only the documents you actually need."""
    return index.get(id).body

if __name__ == "__main__":
    mcp.run()

这里有几件事在做工。docstring 就是模型会读的 tool 描述——它在结构上与系统提示词无异(见面向 agent 的文档),且互相显式引用,好让模型知道 searchfetch 是一个工作流的两半。Hit 这个 Pydantic 模型会成为 tool 响应上的结构化输出 schema;调用方可以依赖这个形状。函数签名上的类型提示——包括 limit 的默认值——会流进 tools/list 发出的 inputSchema,让宿主在调用前就完成参数校验。

客户端在 tools/list 上实际看到的是这样:

{
  "jsonrpc": "2.0", "id": 2, "result": {
    "tools": [
      { "name": "search",
        "description": "Search docs for the top matches. Returns id + summary only; call fetch(id) for the full document body.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "query": {"type": "string"},
            "limit": {"type": "integer", "default": 10}
          },
          "required": ["query"]
        }
      },
      { "name": "fetch",
        "description": "Return the full body of one document by id. Use after search() to pull only the documents you actually need.",
        "inputSchema": {
          "type": "object",
          "properties": {"id": {"type": "string"}},
          "required": ["id"]
        }
      }
    ]
  }
}

两个工具的全部契约就是这些:两个名字、两条写给模型看的描述、两份宿主可以校验的 schema。剩下的——会话管理、传输、分帧——全部由 SDK 处理。

STEP 5

第一台服务器上你必然会踩的坑。

每一台第一次上手的服务器都会撞上同样几种坑,且一致到可以列成清单。

忘了写 annotations。MCP 的 tool 上有一个可选的 annotations 对象,字段包括 destructiveHintidempotentHintopenWorldHint 等,宿主用它们来塑造用户同意 UX——运行前要不要问、能不能静默重试、这次调用会不会触及公网。省略 annotations 会迫使宿主回落到保守默认,一般意味着"每次都问用户",而这一般意味着用户会把你的服务器关掉。

为人类写描述。那种读起来像 docstring 的自由文本 tool 描述——"Retrieves user information from the database"——不是 agent 需要的。描述是模型在选工具时读的文本,因此它应该长成指令:以动词开头、点出使用场景,并说清楚什么时候不要用它。"Get a user by id when you need their email, role, or account status. Do not use for listing users." 读起来对人类来说别扭,实际效果却好得多。

schema 不做版本管理。悄悄改一个参数名会打断所有缓存了 schema 的客户端。微软 Learn 服务器的复盘给出了一个具体数字——一次参数改名会导致 2–5% 的客户端出错——因为这些客户端缓存了改名前的 tool 定义。如果 schema 变更不是纯加法式的,要么给 tool 换一个新名字,要么把服务器版本号推进;就地改名等同于静默破坏。

上线你从不测试的 resource。常见的做法是"感觉应该有 resource"就注册几个,然后发现你唯一的客户端——那个 agent——其实从来没读过它们,因为你的宿主根本没把 resource 呈现给模型。要么真正使用它们、接入真实代码路径、放进测试覆盖,要么就砍掉。死能力比缺能力更糟,因为它在清单里看起来像是"覆盖到了"。

stdio 对 HTTP 的想当然。大量第一台服务器的活儿都假设 stdio 一定是开发用传输、Streamable HTTP 一定是生产用传输。两个方向都错。本地 stdio 服务器正在生产环境里跑——多数嵌入 IDE 的服务器就是——远程 HTTP 服务器在共享的团队开发环境里也完全合理。传输的选择应该根据服务器要跑在哪里、以什么方式鉴权来定,而不是根据你把这套配置视作"开发"还是"生产"。

贯穿这些的主线:那些看起来像装饰的部分——描述、annotations、版本管理纪律、选对原语——才是决定这台服务器会不会从你的调试队列里消失、还是永久驻留其中的部分。前 5 个工具就做对,下游的一切——从选择准确率到轨迹可读性再到鉴权复杂度——都会变便宜。