电视剧《180天重启计划》剧情梗概
2026-07-24 3421286
2026-07-24 0
作 者:吴佳浩Alben
撰稿时间:2026.7.15
更新时间:2026.7.20
先不讲 MCP,讲历史。
flowchart LRA[LLM] --> B[Prompt]B --> C[Function Calling]C --> D[Tool Calling]D --> E[Plugin]E --> F[Agent]F --> G[MCP]
一句话说清楚:
如果你写过 Agent,大概率经历过这几件事:想让 Agent 查一下 GitHub issue,接一遍 GitHub SDK;想操作 Docker,再接一遍 Docker SDK;想查数据库,再写一套 MySQL 连接和权限逻辑。换一个 Agent 框架,前面的活儿全部重来一遍。
flowchart TDA[Agent] --> B{if / else 判断意图}B -->|查代码| C[GitHub SDK]B -->|查数据| D[MySQL SDK]B -->|操作容器| E[Docker SDK]B -->|查缓存| F[Redis SDK]
有了 MCP 之后:
flowchart LRA[Agent] --> M[MCP 协议层]M --> B[GitHub MCP Server]M --> C[Docker MCP Server]M --> D[MySQL MCP Server]
Agent 只依赖 MCP 协议本身,不关心背后是 GitHub 还是别的什么系统。任何 Agent(Claude Desktop、OpenClaw、Hermes、自研 Agent)都能接入任何 MCP Server,真正做到 Plug & Play。
这是最容易混淆的一对概念。
Function Calling 是模型能力层面的机制:给模型一份函数签名(JSON Schema),模型根据当前对话决定"要不要调用、调用哪个、传什么参数"。这套机制是模型自己实现的能力,OpenAI、Anthropic、Google 各家的 Function Calling 接口格式都不完全一样。
但 Function Calling 本身不规定:这个"函数"部署在哪、怎么被发现、怎么跨应用复用、怎么鉴权。同一个函数定义,换一个模型或框架,往往要重新写一遍接入代码——这正是第 02 节里"if/else 耦合"问题的根源。
MCP 是在 Function Calling 之上,补齐了"传输层 + 发现层 + 生态层":统一的协议格式、统一的 Server 部署方式,让"工具"可以脱离具体某个 Agent 框架独立存在,被任意支持 MCP 的模型/Agent 发现和调用。
几乎每个懂后端的读者看到 MCP 的第一反应都是:
一句话讲清楚:
OpenAPI 文档写得再详细,也是假设"调用方是一个懂 HTTP、能解析 JSON Schema、按文档一步步编码接入的程序员或程序"。而 LLM 面对一份几百个字段的 OpenAPI 文档时,既没法像人一样"理解语境",也没法像程序一样"按类型系统硬编码"——它需要的是一份为决策而生的描述:这个工具是干什么的、什么时候该用、传什么参数、会有什么副作用。这正是 MCP 里 Tool 的 Description 存在的意义。
所以准确的说法不是"MCP 取代 OpenAPI",而是:MCP 是在 OpenAPI 描述的能力之上,重新长出的一层"给 AI 看的接口层"。
先看历史上已经存在的这些"标准",它们各自解决的是什么问题:
| 协议/规范 | 解决的问题 | 面向对象 |
|---|---|---|
| REST | 资源的增删改查如何通过 HTTP 表达 | 程序 调 程序 |
| GraphQL | 客户端如何按需查询数据,减少冗余字段 | 程序 调 程序 |
| gRPC | 高性能、强类型的跨服务调用 | 服务 调 服务 |
| OpenAPI | 如何描述一个 REST API 的结构,方便生成文档/客户端 | 人 / 工具链 |
| JSON-RPC | 如何用 JSON 表达一次远程过程调用 | 程序 调 程序 |
| MCP | LLM 如何发现、理解、调用外部能力 | AI 调 工具 |
前面几个协议标准化的都是"接口怎么描述、怎么传输",服务对象始终是程序或人。而 MCP 标准化的是完全不同的东西:
它填补的是一个真空地带,而不是在已有赛道里抢生意,这正是 MCP 能在短时间内被 Claude、OpenAI、Gemini 同时接纳的根本原因。用 USB 类比最直观:
flowchart LRsubgraph USB类比A1[电脑主机] -->|USB接口| B1[USB]B1 --> C1[鼠标]B1 --> C2[键盘]B1 --> C3[U盘]endsubgraph MCP类比A2[Agent] -->|MCP协议| B2[MCP]B2 --> C5[GitHub MCP]B2 --> C6[Docker MCP]B2 --> C7[OpenLog MCP]end
不管外设是什么品牌,只要遵循 USB 标准,电脑就能识别、能用。MCP 也一样——不管后端是什么系统,只要遵循 MCP 协议封装,任何 Agent 都能接入。
这是全网最容易画错的一张图——很多文章把 Client 和 Server 的边界画错,或者漏掉了"谁负责调用 LLM"这一层。
flowchart TDU[User 用户] --> A[Agent / LLM 应用]A --> MC[MCP Client]MC -.MCP协议 stdio/HTTP.-> MS[MCP Server]MS --> T[Tool 工具封装层]MS --> R[Resource 资源层]MS --> P[Prompt 提示词模板层]T --> API[Business API 业务接口]API --> DB[(数据库)]
Client 和 Server 之间只认 MCP 协议,互不关心对方内部怎么实现,这就是解耦的关键。也正因为这样的职责划分:
它不生产能力,只做翻译——把已有系统的能力(REST API、数据库、命令行工具……)翻译成 LLM 能理解、能决策调用的结构化描述。
Tool 是 MCP 里最高频使用的能力。这里先建立一个认知:LLM 根本不会读你的实现代码,它决策时只能看到三样东西:
Tool Name(工具名)Description(描述)InputSchema(输入参数结构)
这决定了 Description 和 Schema 的质量,直接影响 Tool 会不会被正确调用——第四部分会用真实代码展开细讲。
除了"可调用的工具",MCP Server 还可以暴露可读取的资源(比如一份文件、一段日志、一张配置表)。Resource 和 Tool 的区别在于:Tool 是"让 LLM 主动执行一个动作",Resource 是"直接把一段内容提供给 LLM 读取",不需要经过一次"调用决策"。适合暴露那些"读多改少、上下文本身就该带上"的内容。
Server 还可以预置一些提示词模板,供 Client 端直接复用。比如一个代码审查类的 MCP Server,可以内置一个"审查该 PR 的标准 Prompt 模板",减少 Agent 自己重新设计 Prompt 的成本,也保证团队内 Prompt 风格的一致性。
这是更进阶的能力,允许 MCP Server 反过来向 Client 请求"帮我调用一次 LLM 采样"。适用于 Server 内部本身也需要 AI 能力辅助决策的场景——比如一个日志分析 MCP Server,在处理某个 Tool 调用的过程中,可能需要临时借助 LLM 做一次归纳,这时就可以通过 Sampling 反向请求 Client 侧的模型能力,而不用自己额外接一套 LLM API Key。
大部分教程只讲 Tool,是因为 Tool 确实是最高频、最核心的能力,但理解 Resource / Prompt / Sampling 能让你知道:MCP 不只是"工具调用协议",它是一整套"AI 与外部世界交互"的协议族。
把 initialize、list_tools、call_tool 串起来看一次完整的会话生命周期:
flowchart LRA[initialize 握手] --> B[list_tools 发现工具]B --> C[call_tool 执行调用]C --> D["sampling(可选)"]D --> E["resources(可选)"]E --> F["prompts(可选)"]
再展开看一次真实的用户请求,从提问到拿到答案,中间到底发生了什么:
sequenceDiagramparticipant U as 用户participant L as LLMparticipant C as MCP Clientparticipant S as MCP Serverparticipant D as Docker APIU->>L: 帮我查看 Docker 容器状态L->>C: 决定需要调用工具C->>S: list_tools()S-->>C: 返回工具列表(含 get_docker_containers)L->>C: call_tool(get_docker_containers)C->>S: call_tool 请求S->>D: 调用 Docker APID-->>S: 返回容器数据S-->>C: 返回结构化结果C-->>L: 工具结果注入上下文L-->>U: 总结生成自然语言回答
注意关键点:LLM 自己并不知道 Docker API 怎么调,它只知道"有一个叫 get_docker_containers 的工具,我可以调用它"。真正的翻译工作,全部发生在 MCP Server 里。
| 方式 | 场景 | 现状 |
|---|---|---|
| Stdio | 本地进程,Agent 和 MCP Server 在同一台机器 | 官方推荐用于本地工具(如 Claude Desktop 本地插件) |
| SSE | 早期的远程通信方案 | 已被标记为历史方案,不推荐新项目使用 |
| Streamable HTTP | 远程部署,多客户端共享 | 官方目前推荐的远程通信标准 |
简单判断标准:只在本机跑、给自己用 → Stdio;要部署成服务、给团队/多个 Agent 共用 → Streamable HTTP。企业级场景几乎都会走 HTTP,因为 Stdio 依赖进程间管道,没法做鉴权网关、没法做多租户、也没法水平扩展。
把时间线拉长看,MCP 本身也在持续演进:
flowchart LRA["早期:Stdio"] --> B["SSE(历史方案)"]B --> C["Streamable HTTP
(当前推荐)"]C --> D["未来:Gateway / Registry / Authorization
标准化的鉴权与治理层"]
传输方式从"本地进程管道"走向"标准 HTTP",本身就是在为企业级场景铺路——下一步要补齐的,正是标准化的鉴权(Authorization)和治理层(Gateway、Registry),这也是第五部分要重点展开的内容。
先讲清楚职责划分:
flowchart LRA[server.py] --> B[list_tools 声明有哪些工具]A --> C[call_tool 执行工具逻辑]C --> D[封装/调用 REST API]D --> E[整理返回结果给 LLM]
一个 MCP Server 本质只干两件事:告诉 LLM 我有哪些工具(list_tools),LLM 决定调用后真正去执行(call_tool)。
我原始的 OpenLog MCP 其实是把所有东西写进了一个单文件 openlog_mcp.py,这也是它后面被点名批评的问题之一。一个更规范的目录结构应该长这样:
openlog-mcp/├── server.py # 入口:list_tools / call_tool 注册与分发├── tools/│ ├── logs.py# 日志相关 Tool 定义│ ├── docker.py # Docker 相关 Tool 定义│ └── monitor.py # 监控相关 Tool 定义├── api.py # 统一的 api_call 封装(异步 + 统一返回结构)└── config.py# 集中管理环境变量、默认值、启动校验
tools/ 按业务领域拆分文件,api.py 只负责"怎么调用后端",config.py 只负责"配置从哪来、要不要校验"——每个文件职责单一,这一点会在第四部分反复用到。
以 Docker 这一个领域为例,一个真实可跑的 Tool 定义长这样(节选自我的原始实现):
Tool(name="get_docker_containers",description="获取所有 Docker 容器列表及状态",inputSchema={"type": "object", "properties": {}}),Tool(name="operate_docker_container",description="操作 Docker 容器:start/stop/restart",inputSchema={"type": "object","properties": {"source_id": {"type": "string", "description": "Docker 源 ID"},"container_id": {"type": "string", "description": "容器 ID 或名称"},"operation": {"type": "string", "enum": ["start", "stop", "restart"], "description": "操作类型"}},"required": ["source_id", "container_id", "operation"]}),Tool(name="get_container_logs",description="获取 Docker 容器日志",inputSchema={"type": "object","properties": {"source_id": {"type": "string"},"container_id": {"type": "string"},"tail": {"type": "integer", "description": "返回最后 N 行,默认100"}},"required": ["source_id", "container_id"]})
list_tools 返回的是一份"能力清单",Client 在 initialize 之后会主动拉取这份清单,交给 LLM 决策。可以看到,operate_docker_container 已经用 enum 限定了 operation 的取值范围——这是原始代码里为数不多做对了的地方。
call_tool 是真正干活的地方,原始实现长这样:
def api_call(method, path, data=None):"""调用 OpenLog REST API"""url = f"{OPENLOG_URL}{path}"headers = {"Content-Type": "application/json"}if OPENLOG_TOKEN:headers["Authorization"] = f"Bearer {OPENLOG_TOKEN}"body = json.dumps(data).encode() if data else Nonereq = urllib.request.Request(url, data=body, headers=headers, method=method)try:resp = urllib.request.urlopen(req, timeout=30)return json.loads(resp.read().decode())except urllib.error.HTTPError as e:return {"error": f"HTTP {e.code}", "message": e.read().decode()[:200]}except Exception as e:return {"error": str(e)}async def call_tool(name: str, arguments: dict) -> list[TextContent]:if name == "get_docker_containers":result = api_call("GET", "/api/docker/containers")elif name == "operate_docker_container":result = api_call("POST",f"/api/docker/{arguments['source_id']}/{arguments['container_id']}/{arguments['operation']}")elif name == "get_container_logs":result = api_call("GET",f"/api/docker/containers/{arguments['source_id']}/{arguments['container_id']}/logs")# ... 其余 11 个分支,模式完全一致return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
一次真实调用 get_docker_containers,api_call 会向 http://localhost:3003/api/docker/containers 发起 GET 请求,OpenLog 后端返回类似这样的 JSON:
{"containers": [{"id": "a1b2c3", "name": "openlog-api", "status": "running"},{"id": "d4e5f6", "name": "openlog-worker", "status": "exited"}]}
call_tool 最终把上面这段 JSON 原样 json.dumps 之后包进 TextContent 返回。也就是说,LLM 拿到的"工具执行结果",就是一段格式化后的原始 JSON 文本。这里先埋一个伏笔:不同 Tool 返回的字段结构完全不统一,get_docker_containers 返回的是 {"containers": [...]},另一些接口返回的可能是 {"error": "..."} 或裸数组——第四部分会讲为什么这样不够好。
写完 Server,跑起来的方式很统一:在对应 Agent 的 MCP 配置里,指定启动命令即可。以 Stdio 方式为例,配置大同小异:
{"mcpServers": {"openlog": {"command": "python","args": ["/path/to/openlog-mcp/server.py"],"env": { "OPENLOG_URL": "http://localhost:3003" }}}}
initialize 和 list_tools 握手。三端调试的通用排查思路是一致的:先用官方提供的 MCP Inspector 工具确认 Server 能独立跑通,再确认 Agent 端的配置文件路径、环境变量是否正确,最后看 Agent 启动日志里有没有握手成功的记录。跑起来之后,效果是真实可用的:在 Claude Desktop 里问一句"帮我看看 Docker 容器状态",模型会自动选中 get_docker_containers,把上面那段 JSON 转述成"当前有 2 个容器,openlog-api 正在运行,openlog-worker 已停止"这样的自然语言回答。这也证明了 MCP 的开发门槛并不高——一个下午就能把已有系统封装成 MCP。但"能跑"和"写得规范"是两回事,接下来做一次真实复盘。
这一章不是写代码,而是设计思想——也是整篇文章里最值钱的一章。它讲的其实已经不是"MCP"本身,而是一套更通用的方法论:如何设计 Tool。这套方法论不局限于 MCP,未来任何形态的 Agent 工具接入,都绕不开这几步。
很多人(包括我自己第一次写 OpenLog MCP 时)踩的坑,本质上都是同一个:一开始就写代码,把已有 API 一比一翻译成 Tool,跳过了设计阶段。真正应该走的七步是:
type 参数分支的情况;enum 限定的就不要用自由字符串;{success, data, error} 结构,让 LLM 不用每次重新猜字段;api_call,把前面设计好的 Tool 接到真实的业务 API 上。原始代码里 list_tools() 一口气注册了 14 个工具,覆盖日志、监控、Docker、远程服务器、告警、AI 分析、系统设置 —— 7 个完全不同的业务领域挤在同一个 Server("openlog") 里,违反了"特别篇"第二步"划分领域"的原则。
# 原始写法:日志、监控、Docker、告警、设置全部混在一个 Server 里app = Server("openlog")# get_logs / get_monitor_stats / get_docker_containers /# get_machines / get_alerts / trigger_analysis / get_settings# —— 7 个领域,14 个工具,全部注册在同一个 app 上
改进方向:至少拆成 openlog-logs(日志分析)和 openlog-infra(Docker/机器/监控)两个 MCP,各自职责单一,权限也能分开管理,对应第 14 节里那份按 tools/ 子模块拆分的目录结构。
# 原始写法:只说了能做什么,没说什么时候该用、有什么风险description="操作 Docker 容器:start/stop/restart"
这条描述信息量不够,容易在边界情况下误判要不要调用(比如用户只是想"看看"容器状态,结果被联想成要"重启")。
# 更好的写法:说明输入范围、使用场景、副作用description=("操作 Docker 容器的生命周期,支持 start/stop/restart 三种操作。""适用于用户明确要求启动、停止或重启某个容器的场景。""注意:restart 会导致容器内服务短暂中断,执行前建议先确认容器用途。")
一句话记住:Description 要当 Prompt 写,不是当函数注释写。
# 原始写法:source_id / container_id 取值没做任何存在性校验,直接拼进 URL 路径result = api_call("POST", f"/api/docker/{arguments['source_id']}/{arguments['container_id']}/{arguments['operation']}")
问题有两个:一是取参数直接用方括号 arguments['source_id'],一旦 LLM 传参缺失会直接抛 KeyError,而不是给出可读的错误提示;二是 container_id 这类字段没有做格式校验,理论上存在路径穿越风险(比如混入 ../)。
改进方向:Schema 层面能用 enum、pattern 限定的就不要用自由字符串;代码层面取参数统一用 .get(),缺失时返回明确的错误信息而不是让异常直接冒出来。
经验值:一个 MCP 不超过 20 个 Tool。OpenLog MCP 原始版本有 14 个,还没超过这条红线,但因为跨了 7 个领域,实际体验已经打折扣——LLM 在决策阶段要在混杂领域的工具里挑选,选择正确率会下降。数量红线是表象,领域内聚才是本质:宁可多拆几个小 MCP,也不要把无关领域的能力塞进同一个清单。
# 原始写法:不同接口返回的结构完全不统一,成功/失败也没有统一字段result = api_call("GET", f"/api/logs{'?' + qs if qs else ''}")return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
呼应第 17 节埋的伏笔:14 个工具对应的 api_call 返回什么结构,完全取决于后端接口本身长什么样,MCP 层没有做任何统一。
改进方向:在 call_tool 出口统一包一层 {"success": bool, "data": ..., "error": ...},让 LLM 每次拿到的结构都是一致的。
# 原始写法:异常信息直接原样返回,可能带出内部路径、堆栈等敏感信息except Exception as e:return [TextContent(type="text", text=json.dumps({"error": str(e)}, ensure_ascii=False))]
str(e) 有可能包含内部服务地址、文件路径等信息,直接透传给 LLM(进而可能出现在对话里)不太合适。同时,call_tool 是 async 函数,但原始代码里 api_call 用的是同步阻塞的 urllib:
def api_call(method, path, data=None):req = urllib.request.Request(url, data=body, headers=headers, method=method)resp = urllib.request.urlopen(req, timeout=30) # 阻塞事件循环
如果同时有多个工具调用在排队,这一个请求会把整个事件循环卡住,其余请求只能干等。
改进方向:
import httpxasync def api_call(method: str, path: str, data: dict | None = None) -> dict:"""统一的异步调用 + 统一返回结构 + 错误脱敏"""async with httpx.AsyncClient(timeout=30) as client:try:resp = await client.request(method, f"{OPENLOG_URL}{path}", json=data, headers=HEADERS)resp.raise_for_status()return {"success": True, "data": resp.json(), "error": None}except httpx.HTTPStatusError as e:return {"success": False, "data": None, "error": f"后端返回错误状态码 {e.response.status_code}"}except Exception:logger.exception("api_call failed")# 详细异常只记本地日志,不透传给 LLMreturn {"success": False, "data": None, "error": "后端服务暂时不可用"}
换成 httpx.AsyncClient 做真正的异步请求,同时对外只返回脱敏后的错误分类,详细堆栈只记本地日志。
原始代码里,operate_docker_container 这类破坏性操作(start/stop/restart)和 get_docker_containers 这类只读查询,走的是完全一样的调用路径——只要 LLM 决定调用,就会真的执行,中间没有任何权限分级或二次确认机制。
改进方向:区分只读工具和操作型工具,操作型工具建议在 Tool 层面标记风险等级,并要求更高权限的 Token,或者在业务层加一道"需要用户显式确认"的环节,而不是让 LLM 单方面决定就直接生效。企业级场景下,这一层通常会收口到 Gateway,第五部分会展开讲。
一个有点讽刺的事实:OpenLog MCP 本身是"日志分析平台"的封装,但 Server 自己却没有输出任何运行日志。一旦线上调用失败,只能靠猜测排查。
改进方向:至少要记录每次 call_tool 的调用参数(脱敏后)、耗时、成功/失败状态,方便事后排查问题,这也是第 26 节改进代码里 logger.exception 的用意所在。
OPENLOG_URL = os.getenv("OPENLOG_URL", "http://localhost:3003")OPENLOG_TOKEN = os.getenv("OPENLOG_TOKEN", "")# localhost 免鉴权
OPENLOG_TOKEN 默认给空字符串,且没有启动时的校验;limit=100 这样的默认值分散写在多个 call_tool 分支里,属于典型的魔法数字散落问题。
改进方向:统一一个 config.py 模块集中管理默认值和校验逻辑(对应第 14 节的目录结构),启动时如果关键配置缺失,应该给出明确报错而不是静默运行——尤其是部署到非 localhost 环境时,空 Token 意味着完全没有鉴权,这是一个容易被忽略的安全隐患。
很多 CTO 并不关心 Tool、Description、Schema 这些实现细节,他们只关心一个问题:为什么值得投入资源做这件事?
以前,企业每接一个 Agent 场景,都要为每个业务系统单独开发一套接入逻辑:
flowchart TDA[Agent] --> B[ERP SDK]A --> C[MES SDK]A --> D[OA SDK]A --> E[CRM SDK]
现在,只需要把每个业务系统各自封装一次 MCP,剩下的接入工作就不用再重复:
flowchart TDA[Agent] --> M[MCP]M --> B2[ERP MCP]M --> C2[MES MCP]M --> D2[OA MCP]M --> E2[CRM MCP]
答案是:不需要推倒重来,业务零侵入。
flowchart LRA[已有 REST API
SpringBoot/Express/Go/Python] --> B[新增一层 MCP Server]B --> C[Agent]
OpenLog MCP 本身就是最好的例子——OpenLog 后端是一个独立的 Express 服务,MCP Server 完全不碰后端一行代码,只是在外面加了一层"翻译层",把 REST 接口包装成 Tool。呼应第 07 节的结论:MCP Server 是 Adapter,天然就该是新增的适配层,不是对原系统的侵入式改造。
企业 Java 系统最常见的诉求。核心思路不是改造 SpringBoot 本身,而是新增一个独立的 MCP Server 进程,在 Tool 实现里通过 HTTP Client 回调 SpringBoot 已有的 @RestController 接口。Spring AI 生态已经提供了 MCP Server/Client 相关的 Starter 依赖,可以把已有接口逐个包装成 Tool,不需要脱离 Spring 生态另起炉灶。
Go 生态有官方及社区维护的 MCP SDK,思路和 SpringBoot 一致:写一个独立的 MCP Server 进程,在 call_tool 里转发调用已有 Go 服务的 HTTP/gRPC 接口。Go 天生的并发模型和轻量协程,反而很适合承载"多个 Tool 并发转发调用"这种场景,能天然规避第 26 节讲的同步阻塞问题。
就是本文 OpenLog MCP 的例子,用官方 mcp Python SDK 最省心:list_tools / call_tool 两个装饰器即可搭起骨架,配合 stdio_server 或 HTTP 方式对外暴露。Python 生态的 SDK 成熟度目前是几种语言里最高的,适合快速验证和原型开发。
企业里一个 MCP Server 从诞生到退役,通常要经历这样一条链路:
flowchart LRA[开发] --> B[发布]B --> C[注册 Registry]C --> D[Gateway 接入]D --> E[Agent 发现]E --> F[调用]F --> G[日志与监控]G --> H[升级]H --> I[废弃]
大部分团队只关注"开发"和"调用"这两个环节,中间的"注册""接入""监控""升级""废弃"往往是空白的——这正是下面几节要补齐的内容。
企业级场景通常不是一个 MCP,而是一堆 MCP:
flowchart TDA[GitHub MCP]B[Docker MCP]C[OpenLog MCP]D[K8S MCP]E[Jira MCP]A --> G[Gateway 网关层]B --> GC --> GD --> GE --> GG --> RG[(Registry 注册中心)]G --> H[Agent]
这里有一个非常常见的坑:很多团队第一反应是写一个"万能 MCP",把 100 个工具都塞进一个 Server 里。这看起来省事,实际上会造成 LLM 决策正确率下降、权限无法细粒度控制、任何改动都要重新发布整个 Server。正确做法是按领域拆分,上层用 Gateway 统一接入。
企业级部署中,Gateway 承担统一入口的职责:统一鉴权(不需要每个 MCP Server 各自实现一套认证逻辑)、统一限流与审计日志、按用户/团队做访问控制、把多个 MCP Server 聚合成一份对 Agent 可见的清单。这也是第 27 节"权限控制"在企业场景下的落地方式——单个 MCP Server 内部做不到的细粒度权限,交给 Gateway 层统一收口。
Registry 解决的是"治理"问题:企业内部到底有多少个 MCP Server、分别是谁维护的、当前版本是什么、是否还在被使用。类似企业内部的"API 市场"——团队开发新 Agent 时,先去 Registry 查有没有现成的 MCP 可用,而不是重新造一个轮子。没有 Registry 的企业,往往会在半年后发现团队里悄悄长出了三四个功能重叠的 MCP Server,谁都不知道该用哪个。
第 27 节讲的是单个 MCP Server 内部该有的权限意识,企业级场景需要一整套体系:
MCP 平台跑起来之后,企业几乎必然会问:"这些 MCP 到底被用得怎么样?"常见的可观测性指标包括:
这些指标通常也是收口在 Gateway 层统一采集,而不是要求每个 MCP Server 自己实现一套监控上报逻辑。
汇总一下前面几部分的核心结论:
看完前面这么多内容,很容易产生一个误解:以后是不是什么都该用 MCP?REST 是不是就没用了? 并不是。
最后拔高一下视角。几个值得关注的方向:
Q:MCP 和 OpenAPI 有什么区别?REST/OpenAPI 面向程序调用,MCP 面向 AI 决策调用,详见第 04 节。
Q:MCP 和 Function Calling 有什么区别?Function Calling 是模型决定"要不要调用"的能力,MCP 是让工具能被任意模型发现和调用的协议层,详见第 03 节。
Q:MCP 为什么不用 WebSocket?WebSocket 适合双向长连接,但实现和部署复杂度更高。Streamable HTTP 已经能满足"流式返回 + 请求响应"的场景,同时保持了无状态特性,更方便水平扩展和走标准的 HTTP 网关,这也是官方选择它而不是 WebSocket 的原因之一。
Q:HTTP 和 Stdio 怎么选?本地自用选 Stdio,要给团队或多个 Agent 共用就选 Streamable HTTP,详见第 13 节。
Q:一个系统应该写几个 MCP?按业务领域拆分,一个领域一个 MCP,不要写"万能 MCP",详见"特别篇"第二步和第 36 节。
Q:一个 MCP 应该有多少 Tool?经验值不超过 20 个,但数量红线是表象,领域内聚才是本质,详见第 24 节。
Q:MCP 会取代 REST API 吗?不会。REST 继续服务程序间调用,MCP 是在其之上新增的、面向 AI 的消费层,两者并存,详见第 04、42 节。
Q:Skill 和 MCP 有什么关系?Skill 负责"怎么做"(流程编排),MCP 负责"调用什么"(能力封装)。可以理解为:Skill 是剧本,MCP 是演员能做的动作清单。
这篇文章按"认知 → 原理 → 实战 → 复盘 → 企业落地"五个阶段展开,中间穿插了一份真实的、第一次写就跑通但不够规范的代码(OpenLog MCP)——从完整的 Tool 实现,到设计方法论,再到逐条代码复盘,最后落到企业级的权限、可观测性、Gateway、Registry 方案。
如果你也是刚开始写 MCP,不用追求一上来就写出"教科书级"的代码——先建立认知,按"特别篇"的七步设计,再对照第四部分逐一打磨,最后参考第五部分往企业级去演进,也别忘了回头看看第 42 节,想清楚这次是不是真的需要 MCP。这本身就是大多数人写第一个 MCP 的必经之路。