最近很多 AI 应用都在谈 MCP。它很容易被误解成"让模型自动执行代码的框架",也有人把它当成某一家产品的插件格式。更准确的说法是:MCP(Model Context Protocol)是一套让宿主应用与外部能力交换上下文的开放协议。一个 MCP 服务器可以把工具、资源和提示模板以标准方式暴露出来;支持协议的客户端发现这些能力后,再决定是否展示、是否让模型调用、如何把结果送回模型。
理解这层边界非常重要。模型不会因为接入 MCP 就突然获得权限,服务器也不应该因为收到一次工具调用就无条件执行。模型负责提出"我想调用哪个工具、参数是什么",客户端和服务器仍然负责身份、授权、校验、审计与副作用控制。把责任边界想清楚,后面写第一个服务会轻松很多。
本文从一个可运行的本地工具服务开始:它提供城市天气查询和温度单位转换。示例不依赖真实天气 API,避免把演示代码写成带着密钥、网络波动和计费风险的黑盒;真正接第三方服务时,只要把确定性的查询函数替换掉,并保留输入校验与错误处理即可。
一、MCP 在整个调用链里处于什么位置
传统的大模型 API 中,开发者把可调用函数的名称、说明和 JSON Schema 随请求发给模型;模型返回函数名与参数;应用执行函数,再把结果交给模型总结。MCP 并没有取消这个模式,而是把"工具目录、资源、协议传输、发现机制"标准化。一个工具服务可以脱离某个具体聊天产品独立开发,宿主应用只要支持同一协议就有机会复用它。
MCP 中最常用的三个概念是:
- Tools(工具):由模型发起调用的动作,例如查询工单、计算报价、创建草稿。工具有名称、描述、输入 Schema,可能有副作用。
- Resources(资源):可读取的上下文,例如配置文件、知识库片段、某个 URI 对应的内容。资源并不等于工具调用。
- Prompts(提示模板):服务器提供的可复用提示结构,用于把某类任务的上下文组织起来。
工具最容易吸引注意力,也最需要克制。不是每个内部函数都应该暴露给模型。一个"查询订单状态"工具边界清晰、输出可结构化;一个"执行任意 SQL"工具把大量权限、审计和注入风险推给自然语言层,通常不该存在。先把业务能力收敛成小而明确的操作,再考虑接 MCP。
业务系统 MCP服务器 语言模型 MCP宿主/客户端 用户 业务系统 MCP服务器 语言模型 MCP宿主/客户端 用户 #mermaid-svg-LNEnYrodOhVHXfWn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LNEnYrodOhVHXfWn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LNEnYrodOhVHXfWn .error-icon{fill:#552222;}#mermaid-svg-LNEnYrodOhVHXfWn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LNEnYrodOhVHXfWn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LNEnYrodOhVHXfWn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LNEnYrodOhVHXfWn .marker.cross{stroke:#333333;}#mermaid-svg-LNEnYrodOhVHXfWn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LNEnYrodOhVHXfWn p{margin:0;}#mermaid-svg-LNEnYrodOhVHXfWn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LNEnYrodOhVHXfWn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-LNEnYrodOhVHXfWn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LNEnYrodOhVHXfWn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-LNEnYrodOhVHXfWn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-LNEnYrodOhVHXfWn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-LNEnYrodOhVHXfWn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-LNEnYrodOhVHXfWn .sequenceNumber{fill:white;}#mermaid-svg-LNEnYrodOhVHXfWn #sequencenumber{fill:#333;}#mermaid-svg-LNEnYrodOhVHXfWn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-LNEnYrodOhVHXfWn .messageText{fill:#333;stroke:none;}#mermaid-svg-LNEnYrodOhVHXfWn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LNEnYrodOhVHXfWn .labelText,#mermaid-svg-LNEnYrodOhVHXfWn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-LNEnYrodOhVHXfWn .loopText,#mermaid-svg-LNEnYrodOhVHXfWn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-LNEnYrodOhVHXfWn .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LNEnYrodOhVHXfWn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-LNEnYrodOhVHXfWn .noteText,#mermaid-svg-LNEnYrodOhVHXfWn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-LNEnYrodOhVHXfWn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LNEnYrodOhVHXfWn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LNEnYrodOhVHXfWn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LNEnYrodOhVHXfWn .actorPopupMenu{position:absolute;}#mermaid-svg-LNEnYrodOhVHXfWn .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-LNEnYrodOhVHXfWn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LNEnYrodOhVHXfWn .actor-man circle,#mermaid-svg-LNEnYrodOhVHXfWn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-LNEnYrodOhVHXfWn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询北京天气并换算华氏度 tools/list 工具定义与输入Schema 问题 + 可用工具 调用 get_weather(city=北京) tools/call 查询或计算 结构化结果 工具结果 工具结果 面向用户的解释
协议规范要求工具定义包含唯一名称、面向人的描述和有效的 JSON Schema。工具名称建议使用 ASCII 字母、数字、下划线、连字符或点号,且在服务器内唯一。描述不是文档里的客套话:它会影响模型何时选择工具。因此不要写"万能查询",而要写清楚输入单位、时间范围、是否只读、不能做什么。
二、环境与版本说明
本文示例按官方 MCP Python SDK 当前 v2 文档编写,要求 Python 3.10 或更高版本。以 Windows PowerShell 为例,先创建一个隔离目录并安装带 CLI 的 SDK:
powershell
mkdir mcp-weather-demo
cd mcp-weather-demo
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install "mcp[cli]"
python --version
mcp --help
官方 SDK 的 mcp[cli] extra 会提供开发命令;文档示例用 uv run mcp dev server.py 启动 Inspector。若使用 uv,可将安装命令改为 uv add "mcp[cli]"。不要为了这个示例额外安装 Web 框架:本地 stdio 传输已经足够验证工具定义和调用过程。SDK 会根据 Python 类型标注生成输入 Schema,这也是我们坚持为参数写清类型和范围的原因。
需要注意版本漂移。网络文章里仍有不少旧版 FastMCP 或不同导入路径的例子,复制后出现 ImportError 往往不是业务逻辑问题,而是 SDK 主版本不一致。动手前先执行 python -m pip show mcp,并以当前安装版本的官方文档为准。本文采用的导入为 from mcp.server import MCPServer;升级 SDK 后,优先跑 Inspector 验证工具发现,不要直接部署到共享环境。
三、先写一个最小、没有外部依赖的 MCP 服务器
新建 server.py,填入以下代码。天气数据是固定示例,目的在于把协议、类型和错误路径跑通;它不会假装返回实时天气。get_weather 只允许三个城市,避免"任意字符串"在后续接真实 API 时直接成为未限制的下游请求。
python
from mcp.server import MCPServer
mcp = MCPServer("Weather Demo")
WEATHER = {
"北京": {"celsius": 23.0, "condition": "晴"},
"上海": {"celsius": 26.0, "condition": "多云"},
"深圳": {"celsius": 29.0, "condition": "小雨"},
}
@mcp.tool()
def get_weather(city: str) -> dict:
"""查询演示城市的天气。仅支持北京、上海、深圳;返回的是示例数据,不是实时预报。"""
normalized = city.strip()
if normalized not in WEATHER:
supported = "、".join(WEATHER)
return {"ok": False, "error": f"暂不支持该城市,可选:{supported}"}
return {"ok": True, "city": normalized, **WEATHER[normalized], "source": "demo"}
@mcp.tool()
def celsius_to_fahrenheit(celsius: float) -> dict:
"""将摄氏温度换算为华氏温度。输入必须是有限数值。"""
if not -273.15 <= celsius <= 1000:
return {"ok": False, "error": "温度必须位于 -273.15 到 1000 摄氏度之间"}
return {"ok": True, "celsius": celsius, "fahrenheit": round(celsius * 9 / 5 + 32, 2)}
def demo() -> None:
assert get_weather("北京")["ok"] is True
assert get_weather("火星")["ok"] is False
assert celsius_to_fahrenheit(0)["fahrenheit"] == 32
if __name__ == "__main__":
demo()
这里有几个故意保守的设计。城市集合是白名单,而不是把用户传来的字符串原样拼进 URL;结果有统一的 ok 字段,错误也作为结构化数据返回;温度换算限制了物理下界和一个合理的演示上界。后者不是精确科学模型,只是防止 NaN、无穷大或极端数值进入后续计算。真正的天气服务还需要超时、重试、缓存与供应商错误映射,但不应在还没跑通 MCP 前一口气写进去。
执行下面命令启动开发 Inspector:
powershell
mcp dev server.py
终端会给出本地 Inspector 地址。打开后,在 Tools 页面应该能看到 get_weather 和 celsius_to_fahrenheit。调用 get_weather 时输入 {"city":"北京"},再调用 celsius_to_fahrenheit 输入 {"celsius":23},可以观察 SDK 基于类型标注生成的表单和返回值。若 mcp dev 报找不到 npx,是因为 Inspector 本身依赖 Node.js 运行环境;先确认 Node.js 的 npm/npx 是否在 PATH 中。服务器代码本身并不因这一报错而失效。
四、为什么工具说明要写得像 API 合同
模型选择工具时,主要能看到名称、描述和参数 Schema。模糊说明会造成两种相反问题:模型根本不知道何时调用;或者在不该调用时过度调用。比如 get_data(query) 这样的接口,模型不知道"data"是天气、订单还是用户资料,也不知道 query 能否承载敏感信息。相较之下,get_weather(city: str) 明确输入、输出和范围,宿主也更容易在调用前展示风险提示。
描述里应写清四类信息:功能是什么、哪些输入合法、返回值是否实时、是否有副作用。例如"创建发布申请草稿,不会提交;需要 project_id 和变更说明;返回草稿编号"比"发布服务"安全得多。对于读写能力,最好拆成 list_*、get_* 与 create_draft_*、submit_*,而不是一个带 action 参数的大工具。这样权限、审计和确认语义都直观。
参数 Schema 也不是替你完成业务校验。类型标注能阻挡字符串和数字的明显错位,却不能知道订单是否属于当前用户、日期范围是否符合权限、传入路径是否越过工作目录。所有来自模型的参数都应当当作不可信外部输入再验证一遍。模型"通常会照格式调用"不是安全保证。
五、加入资源:把稳定内容与动作分开
一个实用服务器不应只提供工具。对于稳定、可读的内容,资源更贴近语义。例如服务可以向宿主提供"支持城市列表"或"温度单位说明";宿主可在合适的时候读取它们,而不必每次都模拟一次工具动作。官方 SDK 支持通过 URI 模板定义资源。
下面是在同一文件中增加资源的最小例子。它使用 weather://guide 作为自定义 URI;URI 是标识符,不是可以从浏览器公开访问的 URL。若资源来自文件或数据库,读取时仍须进行权限过滤,不能因为它被声明为资源就默认对所有连接开放。
python
from mcp.server import MCPServer
mcp = MCPServer("Weather Demo")
@mcp.resource("weather://guide")
def weather_guide() -> str:
"""返回演示服务的使用边界。"""
return (
"该服务仅提供北京、上海、深圳的固定演示天气数据。"
"温度单位为摄氏度;结果不能用于出行或安全决策。"
)
@mcp.resource("weather://city/{city}")
def city_note(city: str) -> str:
"""返回单个演示城市的说明,不执行外部请求。"""
if city not in {"北京", "上海", "深圳"}:
return "未收录该城市。"
return f"{city} 在此示例中使用固定数据,供协议调试。"
def demo() -> None:
assert "固定演示" in weather_guide()
assert "未收录" in city_note("火星")
if __name__ == "__main__":
demo()
实际项目里,工具和资源如何分工有一条很简单的判断:如果是读取已存在、可缓存、无副作用的信息,优先资源或只读查询工具;如果需要代表用户发起一个动作,才考虑工具。不要为了"全都 MCP 化"把业务服务层原封不动搬出来。服务器应该是面向 AI 调用的窄接口,而不是内部系统的裸露镜像。
六、从本地 stdio 到 HTTP:什么时候该升级
本地开发通常选择 stdio:宿主启动子进程,通过标准输入输出与服务器通信,部署简单,密钥也可以通过进程环境变量提供。它适合个人工具、桌面客户端、本机开发和受控的单用户场景。此时不要在 stdout 输出调试日志,因为协议消息也走 stdout;日志应写到 stderr 或统一日志系统。
当多个用户、远程宿主或云端服务需要共享一个服务器时,再考虑 Streamable HTTP。HTTP 不是"把 stdio 服务前面套一个反向代理"这么简单:你需要 TLS、身份认证、限流、跨域策略、请求日志脱敏、健康检查以及对长连接和取消请求的处理。更重要的是,远程服务的身份边界变得明确,不能把本地环境变量里的管理员凭证顺手带过去。
官方协议对 HTTP 授权采用 OAuth 2.1 相关机制,并明确指出访问令牌应通过 Authorization: Bearer 请求头传递,不能放入 URL 查询参数。MCP 服务器还需要验证令牌确实签发给自己这个资源,而不是接收到任何看似有效的 token 就转发给下游 API。这个要求针对的是"混淆代理"问题:中间服务拿到不属于它的令牌再转给别的系统,会扩大令牌泄露和越权范围。
七、工具结果应该返回什么
很多示例把工具结果做成一段自然语言,例如"北京今天晴,23 度"。这对人好读,对程序却不友好。更可靠的是优先返回结构化字段:city、celsius、condition、observed_at、source、ok。模型可以把它转述给用户,前端也能按字段渲染,服务端还能验证哪些字段不应外泄。
结构化输出并不代表可以把完整数据库行直接返回。应该设计面向任务的 DTO:查询发票状态只返回状态、金额、更新时间和允许展示的摘要;不返回支付令牌、内部备注、完整地址或所有关联账户。字段白名单是一道独立于提示词的防线。对于错误,同样返回稳定的错误码和对用户安全的说明,内部堆栈只进入受保护日志。
还要限制结果大小。一个"搜索文档"工具若一次把上百页原文返回给模型,既增加成本,也给提示注入和隐私泄露提供空间。让工具接受分页、最大条数或摘要长度参数,并在服务端设置硬上限。需要全文时,提供带权限检查的按 ID 读取接口;不要让模型通过一个"search"字段顺手下载整个知识库。
八、把真实 API 接进来前的检查清单
演示服务替换成真实天气、工单或数据库查询前,先逐项回答这些问题:调用者是谁?他能访问哪些对象?每个参数是否有长度、格式和枚举限制?网络请求是否设置连接和读取超时?上游失败时返回什么?日志会不会记录 cookie、令牌或完整个人信息?同一个工具是否可能被模型重复调用导致成本放大?
对于只读查询,最小权限账号和查询条件绑定通常就能解决大半问题。例如按当前用户 ID 查询自己的订单,而不是允许 get_order(order_id) 读取任意订单。对于写操作,尽量先做"创建草稿",再由用户在明确页面确认提交;无法避免直接写入时,至少要求幂等键、二次确认和审计记录。模型发起调用的意图不等于用户已授权副作用。
九、常见错误与排查方向
工具没出现在 Inspector 中。 先检查模块是否能被 Python 导入、装饰器是否在服务器对象上注册、文件中是否有导入阶段异常。再运行 mcp dev server.py 看终端 stderr;不要只看浏览器空页面。
调用时参数类型不对。 检查函数签名是否用了清晰类型,前端表单是否传入 JSON 对象。对于范围、枚举和跨字段关系,仍要在函数体内校验并返回可理解错误。
模型频繁调用不需要的工具。 缩短并具体化工具描述,删除范围重叠的工具,要求宿主对副作用调用弹出确认。不要用"永远不要乱调用"这种无法验证的提示取代接口设计。
远程部署后出现 401/403。 区分认证失败与权限不足,检查 token 的受众、过期时间和 scope。不要把 token 放在查询字符串或错误日志里,也不要为了临时跑通关闭验证。
十、给工具留下一份最小的可执行契约测试
工具是否"能被模型调用"不能只在聊天窗口里试一次。模型的表达会变化,测试应该围绕服务端确定性部分:合法参数的结果形状、非法参数不触发下游、边界值、权限分支和错误码。把核心逻辑提取成普通 Python 函数,就可以不启动宿主、不调用模型而直接验证。装饰器只是把这个函数注册为 MCP 工具,不会替你验证业务正确性。
以下代码展示如何为上一节的固定数据建立小型契约检查。这里没有引入测试框架;运行 python contracts.py 就能得到一个会失败的自检。项目成长后可以迁移到现有的测试体系,但先留下这个最低成本的检查,比完全依赖手工点 Inspector 更可靠。
python
from server import celsius_to_fahrenheit, get_weather
def require_keys(value: dict, *keys: str) -> None:
missing = set(keys) - set(value)
if missing:
raise AssertionError(f"返回缺少字段: {sorted(missing)}")
def demo() -> None:
weather = get_weather("上海")
require_keys(weather, "ok", "city", "celsius", "condition", "source")
assert weather["ok"] is True
assert weather["source"] == "demo"
unknown = get_weather(" 不存在的城市 ")
require_keys(unknown, "ok", "error")
assert unknown["ok"] is False
absolute_zero = celsius_to_fahrenheit(-273.15)
assert absolute_zero["ok"] is True
assert celsius_to_fahrenheit(-273.16)["ok"] is False
if __name__ == "__main__":
demo()
真实项目里,最有价值的是把安全边界也放进契约。比如:无权限用户查询受保护对象必须返回 403;超过 max_results 的请求必须被截断而不是把整库导出;取消请求必须停止下游长耗时任务;一个写入工具使用同一幂等键调用两次时,不能产生两次订单。这些都不是"模型表现不好",而是服务端必须明确承担的契约。
如果工具会调用网络 API,还可以通过依赖注入传入一个假的 HTTP 客户端,在测试中返回固定响应、超时和 500 错误。关键是绝不让测试依赖真实供应商的可用性和账户余额。测试期望也不该硬编码供应商的完整错误文本,而应该检查服务对外承诺的稳定错误码或 ok 字段。这样供应商换版本时,内部适配层变化不会把所有调用方一起拖垮。
十一、从"能调通"走向"能维护"的接口细节
当工具数量从两个变成二十个,命名和分组会直接影响可用性。建议按业务对象与动词命名,例如 ticket.get、ticket.search、release.create_draft,不要混用 getTicket、ticket_query、查工单 等风格。协议允许点号、下划线和连字符,但一个服务器内保持一种风格更容易搜索与授权。名称一旦被多个客户端引用,修改就相当于 API 变更;应保留旧工具一段时间或建立明确迁移期。
返回值也要有演进策略。新增可选字段通常比修改字段含义安全得多。不要今天让 status 返回"处理中",明天改成整数 1 而不通知调用方。对于枚举、时间和金额,优先提供机器可读字段:时间用带时区的 ISO 8601 字符串,金额分开提供整数最小货币单位与币种,状态提供稳定 code 和可展示 label。模型读懂自然语言很容易,人类系统读懂不稳定文案却很痛苦。
工具描述里应说明时效性。例如"返回缓存最多五分钟的库存快照"与"返回实时库存"是不同承诺。没有能力保证实时,就不要用实时这个词。查询结果最好带 observed_at、数据版本或来源;模型在最终回复中就能区分"当前值"和"文档说明"。这种小字段能显著减少用户把过期结果当成当前事实的概率。
还要防止工具之间形成隐式工作流。假设 create_invoice 返回一个临时编号,而 send_invoice 只接受该编号;如果模型跳过第一步或重复第二步,系统应该返回清晰错误,而不是根据最近一次会话猜测编号。会话状态属于宿主或业务服务,不应隐藏在工具名称背后。多步流程如果确实存在,就让每一步都有明确输入、状态和权限,并记录操作者与请求 ID。
十二、日志、可观测性与隐私的取舍
发生一次错误工具调用时,维护者通常想知道模型选择了什么、传了什么参数、服务器到哪一步失败。但"全量打印"不是答案。请求参数可能包括身份证号、地址、会话内容或 API 令牌;工具结果可能包含员工资料和内部 URL。日志设计需要先分类字段:可安全记录的请求 ID、工具名、耗时、状态码、返回条数;需要脱敏的邮箱、手机号、订单号;绝不记录的 access token、refresh token、密码和完整授权头。
可以用相关 ID 串起一次调用:宿主生成 request_id,服务器给每次下游操作生成 operation_id,日志只保存它们和经过脱敏的摘要。用户报告问题时,运维人员凭 ID 回放受权限保护的审计记录,而不是在群里索要完整提示词和截图。对高风险写操作,审计条目还应包含经过确认的用户身份、授权 scope、对象 ID、前后状态和幂等键。
观测指标不必一开始接很重的监控平台。先统计每个工具的调用量、成功率、P95 延迟、拒绝参数数、权限拒绝数、下游超时数和平均返回字节数。若某个工具被异常高频调用,先检查描述是否让模型误用、是否存在循环重试,再考虑限流。若某次升级后 403 激增,检查 scope 与受众校验,而不是直接把权限放宽。数字的意义在于定位边界是否被正确执行。
十三、何时不该用 MCP
MCP 解决的是"把上下文能力交给支持协议的宿主"这一问题。若你的需求只是后端服务调用一个确定性 API,直接写普通 HTTP/RPC 接口往往更少层;若前端只是展示数据,数据库查询和页面 API 更直接;若需要长时间可靠编排,工作流引擎或消息队列可能才是主角。没有模型参与的链路,不必为了时髦包一层 MCP。
同样,不要把 MCP 当作权限系统、知识库或代理框架的替代品。协议可以传输工具调用,不能替代 RBAC/ABAC;可以发现资源,不能替代检索索引;可以让模型提议操作,不能代替事务、补偿和人工审批。明确它只解决一件事,反而更容易在正确位置接入它。
第一次落地时,最推荐的选择是"只读、可枚举、结果可验证"的能力。库存查询、构建状态、日历空闲时间、文档目录都比创建账号、删除数据或批量发消息更适合作为起点。拿十个自然语言问法在 Inspector 中逐一确认:工具能否被发现,参数能否被拒绝,错误是否不泄露内部细节,返回是否足以让宿主解释。这个小闭环跑通后,再把同一套命名、校验、审计和结果约定复制到下一个工具。它比先做一个"可以调用所有系统"的总控工具更快达到可用状态,也更容易在出问题时关闭单个能力而不影响整个应用。
另外要明确数据责任人。一个工具返回的"订单状态"到底以哪个系统为准、数据多久刷新、异常时谁接手,不是模型能决定的事情。把这类责任写进接口说明和运行手册,给结果带上来源与时间戳。工具服务越像一份小型但严谨的业务 API,越不需要用复杂提示词去弥补不确定性。
一个简单的发布前演练是故意输入错误:空白城市、超长字符串、边界温度、无效日期、重复写请求、权限不足身份以及下游超时。每条错误都应有可预测结果:拒绝、限流、超时摘要或幂等返回,而不是进程崩溃、泄露异常栈,或者神秘地成功。把这组案例保留在仓库中,未来更换模型、SDK 或部署方式时就能快速确认工具边界没有被无意放宽。
服务端还要把版本信息当作接口的一部分。SDK、协议版本、业务 API 版本和工具行为各自可能变化;升级前先在隔离环境执行工具列表与契约自检,再对比工具名称、参数和结果字段。尤其不要把一次依赖升级和一次业务能力扩张合并上线,否则出现问题时很难判断是协议兼容性还是业务逻辑变化。小步发布、可回滚配置和明确的弃用期,通常比引入更复杂的兼容层更实用。
十四、小结
MCP 的价值不在于让模型拥有神奇权限,而在于用统一的方式把工具、资源和提示模板接到支持它的宿主中。第一个服务应从边界最清晰的只读能力开始:一个准确的名称、一段可操作的描述、严格类型、服务端校验和结构化结果,已经比"万能工具"可靠得多。
本地阶段用 stdio 和 Inspector 把发现、参数、返回值跑通;准备共享或远程访问时,再补上 HTTP、OAuth、令牌受众校验、最小权限和审计。工具设计得越小,模型越不容易误用,人也越容易复查。下一步如果要暴露写操作,应先把权限、确认与敏感字段处理设计好,而不是先让模型能调用再回头补洞。
十五、一个小服务如何逐步接入真实业务
演示工具替换为真实业务时,不要直接把数据库模型或第三方 SDK 的全部方法暴露出去。先选一个只读、范围明确、数据拥有者清楚的用例,例如"查询当前用户可见的构建状态"或"读取已授权项目的发布窗口"。为它写出输入、输出、权限、时效性与失败语义:输入是否允许模糊搜索,结果最多几条,数据多久刷新,无权访问是统一返回未找到还是明确拒绝,下游超时时用户看到什么。这份说明比先给函数加装饰器重要,因为它决定工具究竟承诺什么。
接入时把 MCP 工具视作一个薄的适配层。认证主体、租户和权限应由宿主请求上下文传入业务服务,而不是由模型填写;业务服务负责对象级授权和查询;MCP 层只负责把已验证的参数与结构化结果映射成协议输出。这样同一条业务规则仍可被网页、移动端和批处理程序复用,也不会出现"通过 MCP 能看到、通过正式页面却看不到"的双重权限逻辑。
发布顺序也应保守。先在测试租户以只读方式运行,记录工具实际被调用的参数分布、错误码、平均结果大小和拒绝率;再开放给少量真实用户,并为每个工具设置开关与负责人。发现描述引发误调用时,可以先关掉单项工具,而不必停止整个服务器。等只读路径稳定后,才讨论草稿、确认和写入能力。写入工具应独立评审,不能因为查询工具安全就自动继承信任。
最后为工具准备退场机制。业务接口下线、字段改名或权限策略变化时,旧工具名称可能还存在于历史提示、客户端缓存或自动化任务中。应在一段弃用期内返回清晰的迁移错误,监控旧调用量,并给新旧版本各自的 Schema 与负责人。直接删除看似最省事,却会把调用失败变成难以定位的模型行为;明确弃用能让维护者知道哪些依赖还没迁走。
工具目录也值得定期做一次清理。长期无人调用、输出字段过宽、功能与其他工具重复的条目,会让模型选择空间变大、权限审核变难。查看调用日志后,删除没有真实用途的工具,合并仅命名不同的只读查询,保留清晰的说明和版本记录。对模型来说,更小的候选集合通常意味着更稳定的选择;对维护者来说,更少的接口意味着更少的权限、监控和兼容负担。MCP 的可复用不等于无限制增长,接口克制本身就是可维护性的组成部分。
每次清理前应先确认是否仍有版本较旧的客户端依赖该工具,并给出可观察的迁移窗口。工具被调用的次数不高,不代表它没有关键业务用途;要结合调用主体、失败后果与替代路径判断。保守地保留短暂兼容期,再用数据决定删除,通常比一次性大范围改名更稳。