☰
MCP协议实战:从零构建标准化LLM工具调用服务
2026/10/1 23:52:57 网站建设 项目流程

1. 从一个真实困境说起:为什么我们需要 MCP

如果你最近半年在折腾 LLM 应用,大概率遇到过这样的场景:你写了一个 Agent,想让它读本地文件、查数据库、调浏览器、跑一段代码,结果每接一个工具就要写一套适配层。今天接 OpenAI 的 function calling 格式,明天换 Claude 的 tool use 格式,后天又要适配某个国产模型的插件协议。工具本身没变,变的只是"怎么把工具描述给模型"这件事的写法。

MCP 就是冲着这个痛点来的。全称Model Context Protocol,翻译过来叫"模型上下文协议",本质是一套标准化的上下文接入规范。它要解决的问题非常具体:让 LLM 应用和外部工具、数据源之间的连接方式统一起来,就像当年 USB 统一了外设接口一样——你不需要为每个鼠标都配一个专用插槽。

我第一次接触 MCP 是在一个需要让模型操作浏览器的项目里。当时团队已经写了三套不同的工具调用代码,维护成本高得离谱。换成 MCP 之后,工具端只需要实现一次 Server,客户端(Host)就能复用。这个体验上的差异,是促使我认真研究它的直接原因。

这篇文章适合三类人看:一是正在做 Agent 工具链、被各种协议折磨的开发者;二是想理解 MCP 到底"协议"在哪里的技术负责人;三是刚听说 MCP、想知道它和普通 API 调用有什么区别的入门者。我会从设计思路、核心机制、实操落地、踩坑排查几个角度,把这件事讲透。

2. MCP 到底是什么:拆开"协议"这两个字

2.1 协议的本质是约定,不是代码

很多人第一次看到 MCP,会以为它是一个框架或者 SDK。其实不是。协议(Protocol)的核心是一份约定——规定好消息长什么样、谁先说话、字段叫什么名字。至于你用 Python 还是 TypeScript 实现,用 stdio 还是 HTTP 传输,那是实现层面的事。

打个比方:HTTP 协议规定了请求行、请求头、请求体的格式,但没规定你必须用 Nginx 还是 Apache。MCP 也一样,它规定了 Host、Client、Server 三方怎么通信,但具体每个 Server 提供什么工具,完全由你自己决定。

这里有个容易混淆的点:热词里有人问"mcp 是软件协议还是硬件协议那个概念叫什么来着"。答案是——MCP 属于应用层协议,和硬件协议(比如 I2C、SPI 那种定义电平、时序的)完全不是一个层面。它跑在传输层之上,关心的是"消息语义",不是"电信号怎么传"。

2.2 三方角色:Host、Client、Server

MCP 的架构里,角色划分非常清晰,理解这三个词,基本就理解了一半:

  • Host(宿主):最终面向用户的应用,比如一个 IDE、一个聊天客户端、一个桌面 Agent。它负责管理多个 Client,并决定什么时候把哪个工具暴露给模型。
  • Client(客户端):Host 内部的一个连接器,一对一地连到一个 Server。它负责协议握手、消息收发、能力协商。
  • Server(服务端):真正提供能力的一方。它对外声明"我能提供哪些工具、哪些资源、哪些提示模板",然后响应 Client 的调用请求。

这个设计的巧妙之处在于解耦。Host 不需要知道 Server 内部怎么实现,Server 也不需要知道 Host 是 IDE 还是命令行工具。中间靠 Client 做翻译。你换一个 Host,Server 照样能用;你加一个新 Server,Host 只要支持 MCP 就能接。

2.3 MCP 提供的三类核心能力

MCP Server 对外暴露的东西,主要分三类,这个分类是理解整个协议的关键:

能力类型英文作用典型例子
工具Tools模型可以主动调用的函数查数据库、发请求、执行命令
资源Resources模型可以读取的数据文件内容、日志、配置
提示Prompts预定义的提示模板代码审查模板、翻译模板

Tools 是"动作",Resources 是"数据",Prompts 是"套路"。三者配合,才能让模型既知道能做什么,又知道拿什么做,还知道按什么格式做。

注意:很多人一上来只关注 Tools,忽略了 Resources 和 Prompts。实际上在知识库类场景里,Resources 才是主力——把文档作为资源暴露出去,比包装成一个"读取文档"的工具更自然。

2.4 和传统 function calling 的区别在哪

这是被问得最多的问题。表面上看,MCP 的 Tools 和 OpenAI 的 function calling 很像,都是"给模型一份函数清单,让它选一个调用"。但差异在三个地方:

第一,标准化程度。function calling 是各家模型自己的格式,字段名、嵌套结构都不一样。MCP 把它抽象成统一的消息格式,模型侧怎么适配是 Host 的事,Server 只管按 MCP 说话。

第二,连接方式。function calling 通常是"进程内"的,工具代码和主程序在一起。MCP 支持跨进程、跨机器,通过 stdio 或网络传输,Server 可以是一个独立运行的服务。

第三,能力发现。MCP 有明确的"能力协商"阶段,Client 连上 Server 后,Server 会告诉它自己支持什么。function calling 一般是硬编码在代码里的。

理解了这三点,你就明白为什么 MCP 会被叫做"LLM 应用的 USB 接口"了。

3. 核心机制拆解:消息、传输与生命周期

3.1 基于 JSON-RPC 的消息格式

MCP 的底层消息格式用的是JSON-RPC 2.0。这是个很成熟的选择,好处是简单、通用、各种语言都有现成库。

一条典型的请求长这样:

{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "query_database", "arguments": { "sql": "SELECT * FROM users LIMIT 10" } } }

对应的响应:

{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "id | name\n1 | Alice\n2 | Bob" } ] } }

注意id字段,它是用来做请求-响应配对的。因为 MCP 支持并发调用,没有 id 就分不清哪个响应对应哪个请求。这个细节在实现 Client 时特别重要,我见过有人图省事用固定 id,结果并发一上来就串了。

3.2 两种主流传输方式

MCP 目前主流的传输方式有两种,选哪种取决于你的部署场景:

stdio(标准输入输出):Server 作为 Host 的子进程启动,通过 stdin/stdout 通信。优点是简单、无需网络配置、天然隔离。缺点是只能本机、一个 Server 一个进程。适合本地工具类 Server,比如文件操作、本地数据库查询。

HTTP + SSE(Server-Sent Events):Server 作为独立服务运行,Client 通过 HTTP 发请求,通过 SSE 接收流式响应。优点是支持远程、支持多客户端。缺点是要处理网络、鉴权、断线重连。适合云端服务类 Server。

提示:热词里出现的wss://开头那种地址,属于 WebSocket 传输的变体。选传输方式时,先问自己一个问题——这个 Server 是给本机用还是给多台机器用?答案基本就决定了选哪种。

3.3 生命周期:从握手到关闭

一个完整的 MCP 连接,生命周期分四个阶段:

  1. 初始化(initialize):Client 发送初始化请求,带上自己的协议版本和客户端能力。Server 返回自己的协议版本、能力清单、以及可选的 instructions。
  2. 能力协商:双方确认都支持哪些特性。比如 Server 声明支持tools和resources,Client 就知道可以调这两类。
  3. 正常通信:Client 可以调tools/list拿工具清单,调tools/call执行工具,调resources/read读资源。
  4. 关闭:任一方可以主动断开,或者进程退出。

这个流程看起来简单,但初始化阶段的版本协商是个坑点。如果 Client 和 Server 的协议版本不匹配,行为是未定义的。我建议在 Server 端做一次显式检查,版本不兼容就直接报错,别硬撑。

3.4 能力协商的细节

能力协商不是走过场,它决定了后续能调哪些方法。Server 在初始化响应里会返回类似这样的结构:

{ "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true, "listChanged": true }, "prompts": { "listChanged": true } } }

listChanged表示当工具清单变化时,Server 会主动通知 Client。subscribe表示 Client 可以订阅某个资源的变更。这些标志位让协议有了"动态性",而不是一次性快照。

实现 Server 时,如果你不支持某个能力,就别在 capabilities 里声明。声明了却不实现,Client 调用时会报错,排查起来很麻烦。

4. 动手实现一个 MCP Server:从零到跑通

4.1 环境准备与依赖选择

先明确目标:我们要写一个能查本地 SQLite 数据库的 MCP Server,暴露一个query工具和一个schema资源。

语言选 Python,因为官方 SDK 成熟,社区示例多。依赖装两个就够:

pip install mcp pip install aiosqlite

mcp是官方 SDK,aiosqlite是异步 SQLite 驱动。为什么用异步?因为 MCP 的通信本身是异步的,同步阻塞会拖慢整个连接。

注意:Python SDK 版本迭代比较快,建议锁定一个具体版本,比如mcp==1.2.0,避免 API 变动导致代码跑不起来。我踩过这个坑,某次升级后Server类的初始化参数变了,排查了半小时。

4.2 定义工具与资源

先写工具定义。MCP 的工具描述用的是 JSON Schema,这点和 function calling 一致:

from mcp.server import Server from mcp.types import Tool, TextContent, Resource import aiosqlite app = Server("sqlite-mcp") @app.list_tools() async def list_tools(): return [ Tool( name="query", description="执行只读 SQL 查询,返回结果表格", inputSchema={ "type": "object", "properties": { "sql": { "type": "string", "description": "SELECT 语句,禁止写操作" } }, "required": ["sql"] } ) ]

这里有个设计决策值得说:为什么只允许 SELECT。因为让模型直接对生产库执行写操作风险太大。哪怕你加了确认机制,也可能因为模型理解偏差造成误删。只读是最稳妥的默认值。

资源定义类似:

@app.list_resources() async def list_resources(): return [ Resource( uri="sqlite://schema", name="数据库表结构", mimeType="text/plain" ) ] @app.read_resource() async def read_resource(uri: str): if uri == "sqlite://schema": async with aiosqlite.connect("data.db") as db: cursor = await db.execute( "SELECT sql FROM sqlite_master WHERE type='table'" ) rows = await cursor.fetchall() schema = "\n".join(r[0] for r in rows if r[0]) return schema raise ValueError(f"未知资源: {uri}")

资源用 URI 标识,这是 MCP 的一个约定。URI 的好处是天然支持层级和命名空间,比如sqlite://schema、file:///path/to/doc。

4.3 实现工具调用逻辑

工具的实际执行逻辑:

@app.call_tool() async def call_tool(name: str, arguments: dict): if name != "query": raise ValueError(f"未知工具: {name}") sql = arguments.get("sql", "").strip() # 安全检查:只允许 SELECT if not sql.lower().startswith("select"): return [TextContent( type="text", text="错误:只允许 SELECT 查询" )] async with aiosqlite.connect("data.db") as db: try: cursor = await db.execute(sql) rows = await cursor.fetchall() columns = [d[0] for d in cursor.description] # 格式化成表格 header = " | ".join(columns) body = "\n".join( " | ".join(str(c) for c in row) for row in rows ) return [TextContent( type="text", text=f"{header}\n{body}" )] except Exception as e: return [TextContent( type="text", text=f"查询失败: {str(e)}" )]

注意异常处理。工具执行失败不应该让整个连接崩溃,而是把错误信息作为正常响应返回给模型。这样模型能看到错误、调整策略、重试。如果直接抛异常断开连接,模型就失去了纠错机会。

4.4 启动与连接测试

启动 Server:

import asyncio from mcp.server.stdio import stdio_server async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ == "__main__": asyncio.run(main())

跑起来之后,怎么测试?最直接的办法是写一个简单的 Client:

from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def test(): params = StdioServerParameters( command="python", args=["server.py"] ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print("可用工具:", [t.name for t in tools.tools]) result = await session.call_tool( "query", {"sql": "SELECT 1 as num"} ) print("查询结果:", result.content[0].text) asyncio.run(test())

跑通这个测试,说明 Server 基本可用了。接下来就是把它接到实际的 Host 里。

4.5 接入 Host 的配置方式

不同 Host 的配置方式不一样,但核心都是告诉它"怎么启动这个 Server"。以常见的配置文件为例:

{ "mcpServers": { "sqlite": { "command": "python", "args": ["/path/to/server.py"], "env": { "DB_PATH": "/path/to/data.db" } } } }

command和args定义了启动命令,env传递环境变量。这种配置方式的好处是Server 的路径、参数、环境都外置了,换机器只要改配置,不用改代码。

提示:路径尽量用绝对路径。相对路径在不同 Host 的工作目录下解析结果不一样,我见过因为这个问题导致 Server 找不到数据库文件的案例。

5. 实战场景:MCP 在真实项目里怎么用

5.1 浏览器自动化:Playwright MCP 的思路

热词里playwright mcp和chrome devtools mcp出现频率很高,说明浏览器自动化是 MCP 最热的应用场景之一。

传统做法是写一堆 Selenium/Playwright 脚本,每个操作都要预先编码。用 MCP 之后,思路变了:把浏览器操作封装成工具(navigate、click、screenshot、get_dom),让模型自己决定点哪里、填什么。

这个转变的意义在于灵活性。面对一个没见过的网页,模型可以先用get_dom看结构,再决定点哪个按钮。这种"探索式"操作,硬编码脚本很难做到。

实现要点:

  • 工具粒度要适中。太细(比如click_by_xpath)模型难用,太粗(比如do_everything)模型没法控制。
  • 截图工具要返回 base64 还是文件路径?返回路径更省 token,但模型看不到内容。建议两个都提供,让模型选。
  • 页面加载要加超时和重试,网络慢的时候模型容易误判。

5.2 知识库接入:Resources 的正确用法

llm wiki知识库、rag和llm wiki这类热词指向一个典型场景:把文档库接给模型。

很多人第一反应是写一个search_docs工具。但更符合 MCP 设计的做法是:把文档作为 Resources 暴露,把检索作为 Tool 暴露。

  • Resources 提供"有哪些文档"和"某文档的全文"。
  • Tools 提供"按关键词检索"和"按语义检索"。

这样模型可以先列资源、再决定读哪个,或者直接调检索工具。两种路径都通,比单一工具灵活。

URI 设计建议用层级结构,比如wiki://docs/getting-started、wiki://docs/api-reference。这样模型能通过 URI 推断文档的组织结构。

5.3 安全工具集成:Burp Suite MCP 的启示

burpsuite mcp、ctf skill与mcp这类热词涉及安全测试场景。把 Burp Suite 的能力通过 MCP 暴露给模型,让模型辅助分析请求、生成测试用例。

这类场景有个共同特点:操作有副作用,且可能不可逆。所以设计时要格外注意:

  • 危险操作(比如发送攻击载荷)要单独成工具,不要和只读操作混在一起。
  • 工具描述里明确写清楚"这个操作会修改目标状态"。
  • 如果可能,加一个 dry-run 参数,让模型先模拟再执行。

我在做类似集成时的经验是:宁可工具多一点、粒度细一点,也不要做一个"万能工具"。因为模型对工具的选择依赖描述,描述越具体,选错的概率越低。

5.4 跨领域工具的通用设计原则

不管是浏览器、知识库还是安全工具,MCP Server 的设计有几条通用原则:

原则说明反例
单一职责一个工具只做一件事manage_user同时增删改查
描述具体说清楚输入输出和副作用"处理数据"
幂等优先能幂等就别做成有状态每次调用都追加日志
错误可读错误信息要能指导模型纠错"Error 500"
粒度适中太细难用,太粗难控execute_anything

这些原则不是 MCP 独有的,但在 MCP 场景下更重要,因为模型是主要使用者,它对工具的理解完全依赖描述。

6. 常见问题与排查技巧实录

6.1 连接类问题

问题:Server 启动后 Client 连不上。

排查顺序:

  1. 手动跑一遍 Server 启动命令,看有没有报错。
  2. 检查command和args的路径是否正确,尤其是相对路径。
  3. 看 Server 是否往 stdout 打了非协议内容。stdio 模式下,stdout 是协议通道,任何 print 都会污染消息流。日志要打到 stderr。

这一条我踩过。当时在 Server 里加了个print("启动成功"),结果 Client 一直报解析错误。查了半天才发现是这行 print 惹的祸。

问题:连接建立后调用工具超时。

大概率是工具执行时间太长。MCP 本身没有强制的超时机制,但 Host 通常有。解决办法:

  • 长任务拆成多个短工具,让模型分步调用。
  • 或者用 Resources 的 subscribe 机制做异步通知。

6.2 协议类问题

问题:llm request failed: provider rejected the request schema or tool payload。

这个报错通常出现在 Host 把 MCP 工具转成模型格式的时候。原因可能是:

  • 工具的 inputSchema 里有模型不支持的 JSON Schema 特性(比如oneOf、$ref)。
  • 工具描述太长,超过了模型的上下文限制。
  • 参数名和模型保留字冲突。

解决思路:把 schema 简化到最朴素的形式。只用type、properties、required、description这几个字段,别用高级特性。

问题:工具清单变了但 Client 没更新。

检查 Server 的 capabilities 里有没有声明listChanged: true,以及变化时有没有主动发通知。如果没声明,Client 会缓存初始清单,不会主动刷新。

6.3 日志与调试

mcp server端的日志如何使用自定义日志管理是个高频问题。核心原则:

  • stdio 模式下,日志必须走 stderr,不能走 stdout。
  • 日志级别建议可配置,默认 INFO,调试时开 DEBUG。
  • 敏感信息(token、密码)不要打进日志。

一个简单的日志配置:

import logging import sys logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", stream=sys.stderr # 关键:走 stderr ) logger = logging.getLogger("sqlite-mcp")

6.4 常见问题速查表

现象可能原因解决方向
Client 连不上启动命令错误 / stdout 被污染手动跑命令 / 日志改 stderr
工具调用超时执行时间过长拆分任务 / 异步通知
schema 被拒用了高级 JSON Schema简化为基础字段
工具清单不更新未声明 listChanged补上能力声明
中文乱码编码未指定统一用 UTF-8
并发调用串号id 重复用递增或 UUID
资源读取失败URI 格式不匹配严格按约定解析

6.5 几个容易忽略的细节

第一,工具描述里的示例很重要。模型对工具的理解,很大程度依赖 description 里的例子。写一句"例如:query('SELECT * FROM users')"比写三段抽象说明都管用。

第二,参数默认值要显式。如果某个参数有默认行为,在 schema 的 description 里写清楚。模型不知道你的代码逻辑,它只看 schema。

第三,错误信息要"可行动"。"查询失败"不如"查询失败:表 users 不存在,可用表有 orders、products"。后者能让模型直接纠正。

第四,版本兼容要留后路。工具的参数结构一旦发布,尽量只增不改。改字段名会让依赖它的 Host 全部失效。

7. 我对 MCP 落地的一些个人体会

折腾了几个月 MCP,最大的感受是:它解决的是"连接"问题,不是"智能"问题。协议再标准,工具设计得不好,模型照样用不明白。所以真正花时间的,不是写 Server 的通信层,而是打磨工具的描述、粒度和错误处理。

另一个体会是,MCP 的价值在多 Host 复用时才真正体现。如果你只服务一个应用,直接写 function calling 可能更省事。但一旦你有多个 Agent、多个客户端,MCP 的标准化优势就出来了——写一次 Server,到处能接。

最后分享一个小技巧:调试 MCP Server 时,先别急着接 Host,用官方 SDK 写个最小 Client 跑通再说。这样能把"协议问题"和"Host 配置问题"分开,排查效率高很多。我一开始图快直接接 Host,结果一个配置错误查了一下午,后来改成先跑最小 Client,十分钟就定位了。

这个方向后续还能往深里做,比如把 Resources 的订阅机制用起来做实时数据推送,或者研究一下多 Server 编排时的能力冲突怎么处理。这些我还在摸索,有进展再单独写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询