MCP 协议深入:AI 应用的 USB-C 接口

2024 年 11 月 Anthropic 开源 MCP(Model Context Protocol)时,很多人以为这只是又一个工具调用的封装。一年半过去,OpenAI、Google 先后宣布兼容,MCP 事实上成了 AI 应用接入外部世界的标准接口。这篇文章不讲新闻,讲协议本身:它解决了什么问题、怎么解决的、生产环境里有哪些坑。

为什么需要 MCP:N×M 问题

在 MCP 之前,每个 AI 应用接入每个数据源都要写一遍胶水代码。你有 N 个 AI 客户端(Claude、Cursor、自研 Agent),M 个数据源(数据库、Slack、内部 API),就要维护 N×M 套集成。每套集成都有自己的认证方式、错误处理、数据格式。

这和 USB 出现之前的外设市场一模一样:每台电脑、每个打印机厂商都有自己的接口。MCP 做的事情就是定义一个统一插口——客户端只要实现一次 MCP Client,就能接入所有 MCP Server;数据源只要实现一次 MCP Server,就能被所有支持 MCP 的应用使用。N×M 变成 N+M。

MCP 架构示意

协议分层:三个核心原语

MCP 基于 JSON-RPC 2.0,传输层支持 stdio(本地进程)和 Streamable HTTP(远程服务)。协议层定义了三个核心原语,理解它们的区别是用好 MCP 的关键:

Tools(工具):模型主动调用的函数。由模型决定什么时候调、传什么参数。比如 query_databasesend_message。这是大家最熟悉的部分,对应传统的 function calling。

Resources(资源):应用控制的上下文数据。注意是「应用控制」而不是「模型控制」——由宿主应用决定把哪些资源注入上下文。比如一个文件的内容、一条数据库记录。Resources 是只读的,有 URI 标识,支持订阅变更。

Prompts(提示模板):用户控制的交互模板。用户在 UI 里显式选择触发,比如斜杠命令。Server 可以暴露参数化的提示模板,把领域知识打包给用户复用。

三者的控制权分别在模型、应用、用户手里。很多人把所有东西都塞进 Tools,结果模型在一堆本该是静态上下文的数据上反复做无谓的工具调用,既慢又贵。正确的做法是:动态操作用 Tools,静态上下文用 Resources,固定工作流用 Prompts。

动手写一个 MCP Server

用官方 Python SDK 写一个查询 MySQL 慢日志的 Server,不到 60 行:

from mcp.server.fastmcp import FastMCP
import aiomysql

mcp = FastMCP("mysql-slowlog")

@mcp.tool()
async def top_slow_queries(limit: int = 10) -> str:
    """查询执行时间最长的慢 SQL,按平均耗时降序"""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.cursor(aiomysql.DictCursor) as cur:
            await cur.execute("""
                SELECT schema_name, digest_text,
                       count_star AS calls,
                       avg_timer_wait/1e12 AS avg_sec
                FROM performance_schema.events_statements_summary_by_digest
                ORDER BY avg_timer_wait DESC LIMIT %s
            """, (limit,))
            rows = await cur.fetchall()
    return "\n".join(
        f"[{r['avg_sec']:.2f}s x{r['calls']}] {r['digest_text'][:120]}"
        for r in rows
    )

@mcp.resource("schema://{table}")
async def table_schema(table: str) -> str:
    """暴露表结构作为只读资源"""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute(f"SHOW CREATE TABLE {validate(table)}")
            return (await cur.fetchone())[1]

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

客户端配置(以 Claude Desktop 为例):

{
  "mcpServers": {
    "mysql-slowlog": {
      "command": "python",
      "args": ["/opt/mcp/slowlog_server.py"],
      "env": { "MYSQL_DSN": "mysql://readonly@db:3306/perf" }
    }
  }
}

注意两个细节:工具的 docstring 会直接成为模型看到的工具描述,写得越精确,模型的调用准确率越高;table_schema 用了 Resource 而不是 Tool,因为表结构是静态上下文,不需要模型反复决策。

生产环境的四个坑

坑一:工具数量爆炸。 接入五六个 MCP Server 后,模型面前可能摆着上百个工具。工具描述全部进入上下文,token 成本飙升不说,模型的选择准确率会显著下降。我的经验阈值是 40 个工具以内;超过就要做工具的动态裁剪——按当前任务语义检索相关工具,而不是全量注入。

坑二:把 MCP Server 当 API 网关用。 有团队把内部 200 个 REST 接口一比一翻译成 MCP 工具,结果模型完全用不好。MCP 工具的粒度应该面向「任务」而非「接口」:与其暴露 get_userget_ordersget_refunds 三个工具让模型自己编排,不如暴露一个 get_customer_overview。模型少做一次编排,就少一次出错的机会。

坑三:安全边界缺失。 stdio 模式下 Server 继承宿主进程权限,prompt injection 可以诱导模型调用危险工具。生产实践必须做三件事:工具按最小权限设计(数据库账号只读)、写操作强制人工确认(MCP 协议支持 elicitation)、对工具返回内容做注入检测——工具返回值同样是不可信输入,这一点最容易被忽略。

坑四:忽略协议版本与能力协商。 MCP 在快速演进,2025-03 引入 Streamable HTTP 取代 SSE,2025-06 增强了 OAuth 资源服务器语义。Client 和 Server 在 initialize 握手时会协商协议版本和能力(capabilities),自研客户端如果硬编码假设对方支持某能力,升级时一定会炸。永远以握手返回的 capabilities 为准。

MCP 不是银弹

最后泼点冷水。MCP 解决的是「连接」的标准化,不解决「使用」的智能化。工具接上了,模型用不用得好是另一回事——这取决于工具描述质量、上下文工程和任务分解,这些活儿 MCP 一个都没替你干。

我自己的类比是 HTTP:重要、必要、值得现在就投入,但没有谁靠「支持了 HTTP」赢过。竞争力在协议之上那一层——接进来的东西能不能组合成解决真实问题的能力。

我们内部第一个 MCP Server 上线到现在四个月,最大的收获其实不是技术上的,而是它逼着各团队把「我们的数据能被 AI 怎么用」这个问题想清楚了。工具描述写不出来的接口,多半是接口本身就没想清楚。这个副作用我觉得比协议本身还值。