1. 为什么要把 REST API 封装成 MCP 服务
手里有一套跑了很久的 REST API,接口稳定、文档齐全、上下游都在用,这时候突然要接大模型或者 AI Agent,最直接的想法是让模型直接调 HTTP。但真上手就会发现,这条路走起来比想象中别扭得多。模型不知道你的接口有哪些、参数怎么填、返回结构长什么样,你得把接口文档塞进上下文里,token 哗哗地烧,模型还经常把参数名写错、把 GET 写成 POST、把路径拼错。更麻烦的是,一旦接口有几十上百个,光是把这些信息喂给模型就够呛,更别提让它稳定地选对接口、填对参数。
MCP(Model Context Protocol)就是来解决这个问题的。它本质上是一套标准化的协议,让模型能够以统一的方式发现和调用外部能力。你可以把它理解成给模型准备的“USB 接口”——不管背后接的是数据库、文件系统、还是你自研的 REST API,只要封装成 MCP Server,模型就能用同一套方式去调用。而 FastMCP 这个 Python 框架,把封装 MCP 服务的门槛降到了很低,配合 Pydantic 做参数校验,基本上就是把现有的 REST API 逻辑重新组织一遍,加上类型注解和文档字符串,就能跑起来。
这篇文章面向的是手里有现成 REST API、想把它接进 AI 工具链的开发者。不管你是想让 Claude、Cursor 这类工具直接调用你的业务接口,还是想在自己的 Agent 里集成内部服务,这套封装思路都能直接用。我会从整体设计讲到具体实现,把参数映射、错误处理、认证透传这些坑一个个拆开讲,最后给出一套可以直接抄的代码结构。读完你应该能独立把一套中等规模的 REST API 封装成工业级可用的 MCP 服务。
2. 整体设计思路与方案选型
2.1 为什么不直接让模型调 HTTP
先说清楚一个前提:模型直接调 REST API 不是不行,而是不稳定、不可维护。我试过让模型直接根据 OpenAPI 文档去构造请求,简单接口还行,一旦涉及嵌套参数、枚举值、必填可选混在一起,出错率就上来了。而且每次调用都要把接口文档塞进上下文,token 成本高得离谱。MCP 的价值在于把“接口发现”和“接口调用”标准化了——模型先通过 MCP 协议拿到工具列表(每个工具带名称、描述、参数 schema),然后按 schema 去填参数,服务端负责校验和执行。这样模型不需要记住你的接口细节,只需要在需要的时候查一下有哪些工具可用。
另一个关键点是职责分离。REST API 关注的是资源操作,MCP 工具关注的是“模型能做什么”。这两者不是一一对应的。比如你的 REST API 可能有/users/{id}/orders和/orders/{id}/items两个接口,但在 MCP 层面,模型可能只需要一个get_user_orders工具,内部去调这两个接口拼出结果。封装的过程其实是一次重新设计,把面向资源的接口重新组织成面向任务的工具。
2.2 FastMCP 与 Pydantic 的分工
FastMCP 负责的是 MCP 协议的实现——工具注册、参数解析、结果返回、错误传递。你只需要用装饰器把函数标记成工具,FastMCP 会自动生成对应的 schema 并处理协议层的通信。Pydantic 负责的是参数校验和类型转换——你定义好参数模型,FastMCP 会把它转成 JSON Schema 暴露给模型,调用时再按模型校验输入。这两个配合起来,基本上就是把“定义函数 + 加类型注解”这件事做到了极致。
选 FastMCP 而不是自己手写 MCP 协议实现,理由很简单:协议本身有不少细节(初始化握手、能力协商、工具列表格式、错误码),自己写容易漏。FastMCP 把这些都封装好了,你专注在业务逻辑上就行。而且它支持 stdio 和 SSE 两种传输方式,本地工具用 stdio,远程服务用 SSE,切换成本很低。
2.3 封装粒度的取舍
封装粒度是第一个要做的设计决策。粒度太细,模型要调很多次才能完成一个任务,上下文里全是中间结果;粒度太粗,一个工具干太多事,参数复杂、复用性差。我的经验是按任务粒度封装,而不是按接口粒度。具体来说,先列出模型可能需要的“任务”,每个任务对应一个 MCP 工具,工具内部可以去调一个或多个 REST 接口。
举个例子,假设你有一个电商后台的 REST API,有商品查询、库存查询、订单创建这几个接口。如果按接口粒度封装,就是query_product、query_stock、create_order三个工具。但模型真正想做的可能是“帮我查一下这个商品还有没有货,有的话下单”。这时候按任务粒度封装一个check_and_order工具会更顺手。当然,细粒度的工具也要保留,因为模型不一定每次都走完整流程。所以实际操作中往往是粗粒度任务工具 + 细粒度原子工具并存,让模型自己选。
2.4 认证与上下文透传的设计
REST API 通常需要认证,MCP 服务怎么处理这个认证是个关键问题。有几种常见方案:一是 MCP 服务自己持有凭证,模型调用时不需要传;二是模型调用时把凭证作为参数传进来;三是通过 MCP 的上下文机制透传。第一种最简单,适合内部服务;第二种灵活但要求模型知道凭证,不太安全;第三种最优雅但实现复杂。
我的建议是默认用第一种,把凭证放在服务端配置里。MCP 服务作为可信中间层,持有调用 REST API 所需的凭证,模型只需要关心业务参数。如果确实需要按用户区分权限,可以在工具参数里加一个user_id之类的标识,服务端根据这个标识去查对应的凭证。这样既保证了安全,又不会让模型接触到敏感信息。
3. 核心细节解析与实操要点
3.1 工具定义:从函数签名到 MCP Schema
FastMCP 的工具定义非常直观,用@mcp.tool()装饰一个函数就行。但要让模型用得好,函数签名和文档字符串得讲究。先看一个基础例子:
from fastmcp import FastMCP from pydantic import BaseModel, Field mcp = FastMCP("my-api-server") class QueryProductParams(BaseModel): keyword: str = Field(description="搜索关键词,支持商品名称或SKU") category: str | None = Field(default=None, description="商品分类,可选") limit: int = Field(default=10, ge=1, le=50, description="返回数量,1-50") @mcp.tool() async def query_product(params: QueryProductParams) -> dict: """根据关键词搜索商品。 返回匹配的商品列表,每个商品包含 id、name、price、stock 字段。 当用户想查找商品、比价、或者确认某个商品是否存在时使用这个工具。 """ # 内部调用 REST API ...这里有几个关键点。第一,参数用 Pydantic 模型而不是散列参数。散列参数虽然写起来简单,但一旦参数多了就难维护,而且没法复用校验逻辑。用模型可以把校验规则、默认值、描述都集中在一处。第二,文档字符串要写清楚“什么时候用”。模型选工具主要靠描述,描述里要包含使用场景,而不只是功能说明。第三,返回类型要明确。虽然 MCP 最终传的是 JSON,但类型注解能帮助 FastMCP 生成更准确的 schema。
3.2 参数映射:REST 参数到 MCP 参数的转换
REST API 的参数风格和 MCP 工具的参数风格往往不一致。REST 常见的有路径参数、查询参数、请求体,MCP 工具就是一组命名参数。映射的时候要注意几个点。
路径参数通常对应资源的唯一标识,在 MCP 工具里就是一个必填的字符串参数。查询参数对应过滤条件,在 MCP 工具里可以是可选参数,带默认值。请求体对应复杂对象,在 MCP 工具里就是一个嵌套的 Pydantic 模型。这里有个坑:REST 的查询参数经常是字符串,但业务上可能是数字或布尔值。比如?page=1&active=true,在 MCP 工具里应该定义成int和bool,让 Pydantic 去做转换,而不是让模型去猜格式。
另一个坑是枚举值。REST API 可能接受status=active|inactive|pending,在 MCP 工具里应该用Literal或Enum定义,这样生成的 schema 会带上枚举约束,模型填错的时候 FastMCP 会直接报错,而不是把错误传给 REST API 再返回一个模糊的 400。
from typing import Literal class ListOrdersParams(BaseModel): status: Literal["active", "inactive", "pending"] = Field( default="active", description="订单状态" ) page: int = Field(default=1, ge=1, description="页码,从1开始") page_size: int = Field(default=20, ge=1, le=100, description="每页数量")3.3 错误处理:让模型能看懂失败原因
REST API 的错误返回通常是 HTTP 状态码加一段 JSON,比如 404 返回{"error": "not found"}。这种错误直接抛给模型,模型往往不知道怎么处理。MCP 工具的错误处理要做两件事:一是把技术错误翻译成业务语言,二是区分可重试和不可重试的错误。
我的做法是在工具内部捕获 REST 调用的异常,根据状态码分类处理。404 这类“资源不存在”的错误,返回一个结构化的结果,告诉模型“这个资源不存在,你可以换个条件再试”。401/403 这类认证错误,直接抛异常,因为模型重试也没用。5xx 这类服务端错误,可以返回一个提示“服务暂时不可用,请稍后重试”,让模型决定要不要重试。
import httpx async def call_rest_api(method: str, path: str, **kwargs): async with httpx.AsyncClient() as client: resp = await client.request(method, f"{BASE_URL}{path}", **kwargs) if resp.status_code == 404: return {"found": False, "message": "资源不存在"} if resp.status_code >= 500: raise RuntimeError("后端服务暂时不可用") resp.raise_for_status() return resp.json()注意:不要把 REST API 的原始错误信息直接透传给模型,尤其是包含堆栈或内部路径的信息。要做一层清洗,只保留模型能理解的部分。
3.4 结果格式化:控制返回内容的体积
REST API 返回的数据往往比模型需要的多。比如查一个商品,接口返回了 30 个字段,但模型只需要 id、name、price、stock 四个。如果原样返回,token 浪费不说,还容易干扰模型的判断。所以在 MCP 工具里做一层结果裁剪是很有必要的。
裁剪的原则是:只返回模型做决策需要的信息,以及用户可能关心的信息。具体来说,标识字段(id)、关键属性(name、price)、状态字段(stock、status)必须保留,时间戳、内部编码、审计字段可以去掉。如果返回的是列表,还要控制条数,默认返回前 N 条,并在结果里说明总数,让模型知道还有更多。
def trim_product(raw: dict) -> dict: return { "id": raw["id"], "name": raw["name"], "price": raw["price"], "stock": raw["stock"], } @mcp.tool() async def query_product(params: QueryProductParams) -> dict: data = await call_rest_api("GET", "/products", params=params.model_dump(exclude_none=True)) items = [trim_product(item) for item in data["items"][:params.limit]] return {"total": data["total"], "items": items}4. 实操过程与核心环节实现
4.1 项目结构与依赖准备
一个工业级的 MCP 服务项目,结构上要清晰,方便后续扩展。我习惯按下面的方式组织:
mcp-server/ ├── pyproject.toml ├── src/ │ └── mcp_server/ │ ├── __init__.py │ ├── server.py # FastMCP 实例和工具注册 │ ├── client.py # REST API 客户端封装 │ ├── models.py # Pydantic 参数模型 │ ├── tools/ │ │ ├── product.py # 商品相关工具 │ │ └── order.py # 订单相关工具 │ └── config.py # 配置管理 └── tests/依赖方面,核心就是fastmcp、httpx、pydantic。httpx用来调 REST API,支持异步,比requests更适合 MCP 这种可能并发的场景。pydantic通常随 FastMCP 一起装了,但建议显式声明版本,避免兼容问题。
[project] dependencies = [ "fastmcp>=2.0", "httpx>=0.27", "pydantic>=2.0", ]4.2 REST 客户端封装:统一超时、重试与认证
不要在每个工具里直接写httpx调用,而是封装一个客户端类,统一处理超时、重试、认证、日志。这样工具函数只需要关心业务逻辑,不用重复处理这些横切关注点。
import httpx from tenacity import retry, stop_after_attempt, wait_exponential class RestClient: def __init__(self, base_url: str, token: str, timeout: float = 10.0): self.base_url = base_url self.headers = {"Authorization": f"Bearer {token}"} self.timeout = timeout @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5)) async def request(self, method: str, path: str, **kwargs) -> dict: async with httpx.AsyncClient(timeout=self.timeout) as client: resp = await client.request( method, f"{self.base_url}{path}", headers=self.headers, **kwargs ) resp.raise_for_status() return resp.json()这里用tenacity做重试,只对网络层面的失败重试,业务错误(4xx)不重试。超时设 10 秒是个经验值,太短容易误判,太长会拖慢模型响应。如果某个接口确实慢,可以在工具层面单独设更长的超时。
4.3 工具注册:把 REST 接口映射成 MCP 工具
有了客户端和参数模型,工具注册就是水到渠成的事。以商品查询为例,完整实现如下:
from fastmcp import FastMCP from .client import RestClient from .models import QueryProductParams mcp = FastMCP("ecommerce-mcp") client = RestClient(base_url="https://api.example.com", token="...") @mcp.tool() async def query_product(params: QueryProductParams) -> dict: """根据关键词搜索商品。 返回匹配的商品列表,每个商品包含 id、name、price、stock 字段。 当用户想查找商品、比价、或者确认某个商品是否存在时使用这个工具。 """ query = params.model_dump(exclude_none=True) data = await client.request("GET", "/products", params=query) items = [trim_product(item) for item in data["items"][:params.limit]] return {"total": data["total"], "items": items}注意model_dump(exclude_none=True)这个细节。REST API 通常不希望收到null值的查询参数,用exclude_none可以把未填的可选参数去掉,只传实际有值的。这样既符合 REST 习惯,也避免了后端因为收到null而报错。
4.4 启动与传输方式选择
FastMCP 支持 stdio 和 SSE 两种传输。stdio 适合本地工具,比如在 Cursor、Claude Desktop 里配置的 MCP 服务,进程由客户端启动,通过标准输入输出通信。SSE 适合远程服务,MCP 服务独立部署,客户端通过 HTTP 连接。
if __name__ == "__main__": # 本地 stdio 模式 mcp.run(transport="stdio") # 或者远程 SSE 模式 # mcp.run(transport="sse", host="0.0.0.0", port=8000)选哪种取决于使用场景。如果是个人开发者在本地用,stdio 最简单,不需要额外部署。如果是团队共享,或者要接多个客户端,SSE 更合适。SSE 模式下要注意加认证,不能让任何人都能连上来调你的接口。
4.5 参数校验的边界情况处理
Pydantic 的校验很强,但有些边界情况需要额外处理。比如字符串参数的空值,Pydantic 默认允许空字符串,但业务上可能不允许。可以用min_length=1约束。再比如日期参数,REST API 可能接受2024-01-01这种格式,Pydantic 的date类型能自动解析,但模型可能传2024/01/01,这时候会校验失败。可以在字段上加pattern约束,或者在工具内部做一次规范化。
from datetime import date from pydantic import field_validator class DateRangeParams(BaseModel): start_date: date = Field(description="开始日期,格式 YYYY-MM-DD") end_date: date = Field(description="结束日期,格式 YYYY-MM-DD") @field_validator("end_date") @classmethod def check_range(cls, v, info): if "start_date" in info.data and v < info.data["start_date"]: raise ValueError("结束日期不能早于开始日期") return v这种跨字段校验用field_validator很合适,能在参数进入业务逻辑之前就拦住错误,返回清晰的提示给模型。
5. 常见问题与排查技巧实录
5.1 模型选错工具或填错参数怎么办
这是最常见的问题。模型选错工具,通常是因为工具描述不够清晰,或者多个工具的描述有重叠。解决办法是在描述里明确写出“什么时候用”和“什么时候不用”。比如两个查询工具,一个查商品一个查订单,描述里要写清楚“查商品信息用这个,查订单状态用另一个”。参数填错,通常是 schema 约束不够。检查一下枚举值有没有用Literal,数字范围有没有用ge/le,必填项有没有漏标。
还有一个技巧是在工具返回结果里加提示。如果模型传了一个不存在的分类,返回结果里可以带一句“分类 X 不存在,可用分类有 A、B、C”,模型看到之后下一轮就会修正。
5.2 调用超时或后端不稳定怎么处理
REST API 不稳定是常态,MCP 服务要做好容错。前面提到的重试机制能解决一部分问题,但重试次数不能太多,否则模型等太久。我的经验是重试 2-3 次,每次间隔指数退避,总耗时控制在 15 秒以内。如果还是失败,返回一个明确的错误信息,让模型决定是换个方式还是告诉用户稍后再试。
另外要注意区分超时和业务错误。超时是网络层面的,可以重试;业务错误是逻辑层面的,重试没用。在客户端封装里要把这两类分开处理,不要一股脑重试。
5.3 返回结果太大导致上下文爆炸
这个问题在查询类工具里特别常见。解决办法有三层:一是在工具层面限制返回条数,默认返回前 10-20 条;二是裁剪字段,只返回必要信息;三是分页,如果模型需要更多,让它用分页参数再查一次。三层结合起来,基本能控制住返回体积。
如果确实需要返回大量数据,可以考虑把结果写到文件里,返回文件路径给模型。不过这种方式要求模型能读文件,不是所有场景都适用。
5.4 认证凭证怎么管理
凭证管理是个安全敏感问题。绝对不要把凭证硬编码在代码里,也不要把凭证作为工具参数让模型传。正确的做法是用环境变量或配置文件,MCP 服务启动时读取。如果是多租户场景,可以在工具参数里加一个租户标识,服务端根据标识去查对应的凭证。
import os TOKEN = os.environ["REST_API_TOKEN"]注意:如果 MCP 服务要部署到远程,SSE 模式下一定要加访问控制,不能让任何人都能连上来。否则别人可以通过你的 MCP 服务调用你的 REST API,相当于把你的凭证泄露了。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型不调用工具 | 工具描述不清晰 | 检查描述是否包含使用场景 |
| 参数校验失败 | schema 约束不足 | 检查枚举、范围、必填项 |
| 调用超时 | 后端慢或网络问题 | 检查超时设置和重试策略 |
| 返回结果太大 | 未裁剪或未分页 | 检查字段裁剪和条数限制 |
| 认证失败 | 凭证过期或配置错误 | 检查环境变量和凭证有效期 |
| 工具选错 | 多个工具描述重叠 | 明确各工具的适用场景 |
6. 进阶优化与扩展思路
6.1 工具分组与动态加载
当工具数量超过 20 个,模型的选择准确率会下降。这时候可以考虑按领域分组,比如商品工具、订单工具、用户工具,每组一个 MCP Server,或者用 FastMCP 的命名空间功能。另一种思路是动态加载,根据当前上下文只暴露相关工具,减少干扰。不过动态加载实现复杂,一般项目用分组就够了。
6.2 缓存与性能优化
查询类工具如果被频繁调用,可以加一层缓存。简单的做法是用functools.lru_cache缓存纯函数的结果,但要注意缓存失效。更稳妥的做法是用 Redis 之类的外部缓存,设置合理的 TTL。缓存能显著降低后端压力,也能加快模型响应。
6.3 日志与可观测性
MCP 服务的日志很重要,出问题的时候全靠它排查。建议记录每次工具调用的入参、出参、耗时、错误信息。日志要结构化,方便后续分析。如果团队有可观测性平台,可以把这些指标接进去,监控调用量、成功率、延迟分布。
import logging import time logger = logging.getLogger("mcp") async def logged_call(tool_name: str, params: dict, func): start = time.time() try: result = await func() logger.info(f"{tool_name} success", extra={"params": params, "duration": time.time() - start}) return result except Exception as e: logger.error(f"{tool_name} failed", extra={"params": params, "error": str(e)}) raise6.4 测试策略
MCP 工具的测试分两层:单元测试和集成测试。单元测试用 mock 替换 REST 客户端,验证参数映射和结果裁剪的逻辑。集成测试用真实的 REST API(或者 mock server),验证端到端的调用链路。FastMCP 提供了测试工具,可以直接调用工具函数并检查返回结果。
import pytest from mcp_server.tools.product import query_product from mcp_server.models import QueryProductParams @pytest.mark.asyncio async def test_query_product(mock_client): result = await query_product(QueryProductParams(keyword="test")) assert "items" in result assert len(result["items"]) <= 10我个人在实际操作中的体会是,封装 MCP 服务最难的不是技术实现,而是设计好工具的粒度和描述。技术层面 FastMCP 和 Pydantic 已经把大部分脏活干了,但工具怎么划分、描述怎么写、返回什么字段,这些决策直接影响模型的使用效果。我的建议是先用粗粒度工具跑通流程,观察模型的实际调用行为,再根据问题逐步细化。不要一上来就设计几十个细粒度工具,那样调试起来很痛苦。另外,日志一定要早加,模型调用工具的过程是个黑盒,没有日志根本不知道它为什么选错工具、为什么填错参数。