2423 字
约 8 分钟
15
S21-X03 · 什么是 MCP:做出第一个 MCP 服务

给 Agent 接一个工具,今天是道乘法题:4 个宿主 × 6 个内部系统 = 24 份适配代码,每份都要单独维护参数 schema、单独处理鉴权和超时。MCP(Model Context Protocol)把这道乘法改成加法——每个系统写一次 Server,每个宿主实现一次 Client,中间那层乘积消失。

下面不铺概念,直接从一个"查服务器状态"的小工具开始,把角色、原语、传输、注册、调试和几个必踩的坑一次走完。协议基线为 2024-11-05,涉及远程传输处标注 2025-03-26 起的修订。


1. N×M 是怎么变成 N+M 的

做法 集成成本 换宿主时 换模型厂商时
每个宿主内嵌自家 function calling N×M 全部重写 参数 schema 重写
内部 HTTP API + 每家一个适配层 仍是 N×M 适配层重写 适配层重写
MCP Server + 宿主自带 Client N+M 只改一行注册配置 协议不变

MCP 没有发明新能力,它固定的是"描述与调用"的形状:tools/list 返回的 inputSchema 是标准 JSON Schema,任何宿主都能把它渲染成自家的 function 定义;tools/call 返回的 content 是纯文本或图片块,任何宿主都能塞回上下文。形状固定了,乘积就塌成了和。


2. 三个角色:Host、Client、Server

角色 是什么 谁来实现 典型例子
Host 用户面对的那个 AI 应用,管模型、管上下文、管权限 应用厂商 Claude Desktop、Codex、IDE 插件
Client Host 内部与单个 Server 的 1:1 连接器 宿主随 MCP SDK 实现 每个 server 对应一个 client 实例
Server 暴露 tools / resources / prompts 的独立进程 你 一个 Python 或 TS 服务

关键在 1:1:一个 Agent 接 10 个 MCP 服务 = 10 个 Client 连接,不是 10 个 Host。所以工具名冲突、鉴权隔离、超时都是每条连接各自的事,不是全局的事。宿主要给用户展示"这个工具来自哪个 server",靠的就是这个一对一的边界。


3. 三种能力原语,加一种反向请求

原语 谁发起 谁决定用不用 典型用途 方法
tools Server 声明,模型调用 模型 有副作用的动作:查库、发消息、执行命令 tools/list、tools/call
resources Server 声明,应用读取 人 / 应用 只读数据:文件、表结构、日志片段 resources/list、resources/read
prompts Server 声明,用户选用 人 预置模板、工作流入口 prompts/list、prompts/get
sampling Server 反向请求 宿主 Server 借宿主的模型做一次生成 sampling/createMessage

一句口诀:要模型自己决定"做不做"→ tools;要人决定"看不看"→ resources;要人点一下"跑不跑"→ prompts。

sampling 的方向是倒的,这点最容易被忽略:Server 通常不持有模型凭证,需要生成时向 Client 发 sampling/createMessage,由宿主决定用哪个模型、要不要弹人工确认。它是"服务端无密钥"设计的支点——你的 MCP 服务可以不碰任何 API key 就完成摘要、改写、分类。


4. 传输:stdio 与 Streamable HTTP

传输 起始版本 形态 适用
stdio 2024-11-05 宿主 spawn 子进程,stdin/stdout 走换行分隔 JSON,日志走 stderr 本机工具、单用户
HTTP+SSE(两段式) 2024-11-05 先 GET /sse 拿 endpoint,再 POST 消息 已被取代,只为兼容老客户端保留
Streamable HTTP 2025-03-26 单端点 POST/GET,需要时升级为 SSE 流 远程、多租户、需鉴权

stdio 的硬约束只有一条,但违反它的代价最大:stdout 上只能出现协议消息。你在工具里写一句 print("debug"),宿主就收到一行不合法的 JSON-RPC,轻则 parse error,重则整条会话断掉,而错误日志不会告诉你是哪一行 print 干的。

mcp.run()                                 # 默认 stdio
mcp.run(transport="streamable-http")      # 端点 /mcp,见第二篇

5. 最小骨架:FastMCP + 一个 @mcp.tool()

import shutil
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("ops-tools")

@mcp.tool()
def disk_usage(path: str = "/") -> str:
    """返回指定挂载点的磁盘使用率。path 必须是已挂载的绝对路径。"""
    st = shutil.disk_usage(path)
    return f"{path} used={st.used / st.total:.1%} free={st.free >> 20}MB"

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

FastMCP 从函数签名生成 inputSchema,从 docstring 生成 description。docstring 第一行就是模型看到的工具描述——它是给模型读的 prompt,不是给人读的注释。"返回指定挂载点的磁盘使用率"比 "get disk usage" 命中率高得多,因为前者写清了输入约束和输出形状。

生成的 tools/list 响应大致长这样(典型响应,非实测输出):

{
  "jsonrpc": "2.0", "id": 1,
  "result": {"tools": [{
    "name": "disk_usage",
    "description": "返回指定挂载点的磁盘使用率。path 必须是已挂载的绝对路径。",
    "inputSchema": {
      "type": "object",
      "properties": {"path": {"type": "string", "default": "/"}},
      "required": []
    }
  }]}
}

参数要更严的约束就显式挂 Field,描述里直接写枚举——不少宿主并不渲染 enum 的下拉框,模型只能读描述:

from pydantic import Field

@mcp.tool()
def restart_service(
    name: str = Field(description="服务名,取值只能是 web、api、worker 之一"),
    dry_run: bool = Field(default=True, description="true 只打印将执行的命令,不真重启"),
) -> str:
    """重启一个后端服务。默认 dry_run,确认无误后再传 false。"""
    ...

6. 注册到 Claude Desktop 与 Codex

Claude Desktop 用 JSON,路径 macOS ~/Library/Application Support/Claude/claude_desktop_config.json,Windows %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "ops-tools": {
      "command": "/opt/venv/bin/python",
      "args": ["/opt/mcp/ops_server.py"],
      "env": {"OPS_ENV": "prod"}
    }
  }
}

Codex 用 TOML,路径 ~/.codex/config.toml:

[mcp_servers.ops-tools]
command = "/opt/venv/bin/python"
args = ["/opt/mcp/ops_server.py"]

[mcp_servers.ops-tools.env]
OPS_ENV = "prod"
宿主 配置文件 格式 远程 server
Claude Desktop claude_desktop_config.json JSON "url": "https://host/mcp"
Codex CLI ~/.codex/config.toml TOML 同结构加 url 键
自研宿主 自定义 — SDK 的 Client + streamablehttp_client()

两个细节:command 必须是宿主进程 PATH 里能找到的可执行文件,虚拟环境里请写绝对路径,不要写 python;改完配置要完全退出宿主再重启,关窗口不算,很多宿主只在启动时读一次配置。


7. 调试:Inspector 与手工喂 stdin

npx @modelcontextprotocol/inspector /opt/venv/bin/python /opt/mcp/ops_server.py

它起一个本地 Web 界面,列出 tools/resources/prompts,能直接填参数调用并查看原始 JSON-RPC 报文。协议层的问题在 Inspector 里十分钟能定位,在宿主里只能看到一句"工具调用失败"。

不起 GUI 时手工喂三条消息就够验证握手和列表:

printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | /opt/venv/bin/python /opt/mcp/ops_server.py

顺序不能颠倒:initialize → notifications/initialized → 业务方法。少了那行通知,部分实现会拒绝对 tools/list 作答。


8. 排错:六种"看起来像玄学"的失败

现象 真正原因 处理
宿主提示 server 启动失败 command 不在 PATH / 用了相对路径 写绝对路径,先在终端手动跑一遍
启动成功但工具列表是空的 stdout 被日志污染,解析器读断 所有输出改 print(..., file=sys.stderr) 或 logging 到 stderr
工具在列表里,模型从不调用 description 太泛("处理数据") 描述写清"何时用、参数含义、返回什么"
调用卡住,无任何响应 工具内部阻塞且没有超时 工具内设超时,超时返回错误文本而不是抛异常
中文参数变乱码 未声明 UTF-8 环境变量 PYTHONIOENCODING=utf-8;自实现按行读要 encoding="utf-8"
第一次调用必超时 冷启动 + 顶层 import 重依赖 预热进程,把重依赖移出模块顶层

第一条和第三条是 90% 的新手事故。尤其第三条:模型不调工具,八成不是模型笨,是描述没写清"这个工具解决什么问题"。


9. 边界:什么时候不该上 MCP

场景 选择 理由
一次性脚本里的单次调用 直接 function calling 起进程 + 握手的固定开销不划算
同一批工具要给多个宿主复用 MCP 这正是 N+M 的场景
模型需要读大文件 用 resources,不要让 tool 返回全文 resource 可按需分段读、可被宿主缓存
强低延迟的同步链路 谨慎 stdio 换行解析、HTTP 跳转都有固定成本
调用前必须人工审批 宿主侧确认 / prompts 协议本身不做审批,别指望 server 拦

MCP 是连接层标准,不是 Agent 框架。它不规定 planner、不规定记忆、不规定多 Agent 协作——那些是 LangGraph、CrewAI 的地盘。把 MCP 当框架用,只会得到一堆没有编排的服务。


10. 自测

  1. N×M 里的 N 和 M 分别指什么,MCP 靠什么机制把它压成 N+M?
  2. 一个 Host 接 10 个 MCP 服务时,Client 实例有几个?
  3. "删除一条工单"应该做成 tools 还是 resources,判断依据是什么?
  4. sampling 为什么是 Server 发起而不是 Client 发起?
  5. Inspector 一切正常、宿主却报 parse error,最先怀疑什么?
  6. 工具 description 的读者是谁,它应该包含哪三类信息?
S21-X03 · 什么是 MCP:做出第一个 MCP 服务
https://www.clxhxhhr.top/posts/5686/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。