通过一个DEMO理解MCP(模型上下文协议)的生命周期

在LLM应用的快速发展中,一个核心挑战始终存在:如何让模型获取最新、最准确的外部知识并有效利用工具?

背景其实很简单:大模型(LLM)再强,也总有不知道的东西,怎么办?让它"查资料""调工具"成了近两年最热的技术方向。从最早的 RAG(Retrieval-Augmented Generation),到 OpenAI 引领的 Function Call,再到现在 Anthropic 抛出的 MCP(Model Context Protocol),每一代方案都在试图解答一个问题:模型如何以更自然的方式获得外部世界的帮助?

MCP 主打的是统一标准和跨模型兼容性。虽然协议本身尚处于早期阶段,设计也远称不上完美,但出现的时机十分巧妙。。就像当年 OpenAI 的 API,一旦形成事实标准,后面哪怕有点毛病,也可以很快改进,毕竟生态具有滚雪球效应,一旦用户基数形成规模,自然而然就成为事实标准。

本篇文章将结合 MCP 官方 SDK,通过代码和流程图模拟一次带 Tool 调用的完整交互过程,了解并看清 MCP 的全生命周期。

整体流程

一次MCP完整的调用流程如下:

图1. 一次包含MCP调用的完整流程

图1省略了第一步与第二步之间,list_tools()或resource()的步骤,也就是最开始MCP Host知道有哪些可用的工具与资源,我们在本 DEMO 中使用了硬编码的方式将资源信息构建在提示词中。

这里需要注意的是MCP Client与MCP Host(主机)并不是分离的部分,但为了时序图清晰,这里将其逻辑上拆分为不同的部分,实际上MCP Host可以理解为我们需要嵌入AI的应用程序,例如 CRM 系统或 SaaS 服务,实际上Host中是包含MCP Client的代码。实际的 MCP Host 与 Client 结构如下图所示:

整体示例代码

MCP Server

mcp server的代码使用最简单的方式启动,并通过Python装饰器注册最简单的两个工具,为了DEMO简单,hard code两个工具(函数)返回值,代码如下:

复制代码
#mcp_server_demo.py
from mcp.server.fastmcp import FastMCP
import asyncio

mcp = FastMCP(name="weather-demo", host="0.0.0.0", port=1234)

@mcp.tool(name="get_weather", description="获取指定城市的天气信息")
async def get_weather(city: str) -> str:
    """
    获取指定城市的天气信息
    """
    weather_data = {
        "北京": "北京:晴,25°C",
        "上海": "上海:多云,27°C"
    }
    return weather_data.get(city, f"{city}:天气信息未知")

@mcp.tool(name="suggest_activity", description="根据天气描述推荐适合的活动")
async def suggest_activity(condition: str) -> str:
    """
    根据天气描述推荐适合的活动
    """
    if "晴" in condition:
        return "天气晴朗,推荐你去户外散步或运动。"
    elif "多云" in condition:
        return "多云天气适合逛公园或咖啡馆。"
    elif "雨" in condition:
        return "下雨了,建议你在家阅读或看电影。"
    else:
        return "建议进行室内活动。"

async def main():
    print("✅ 启动 MCP Server: http://127.0.0.1:1234")
    await mcp.run_sse_async()

if __name__ == "__main__":
    asyncio.run(main())

大模型调用代码

大模型调用选择使用openrouter这个LLM的聚合网站,主要是因为该网站方便调用与测试不同的模型,同时网络环境可以直接连接而不用其他手段。

代码如下:

复制代码
# llm_router.py
import json
import requests

# OpenRouter 配置
OPENROUTER_API_KEY = '这里写入使用的Key'
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"

OPENROUTER_HEADERS = {
    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
    "Content-Type": "application/json",
    "HTTP-Referer": "http://localhost",
    "X-Title": "MCP Demo Server"
}


class OpenRouterLLM:
    """
    自定义 LLM 类,使用 OpenRouter API 来生成回复
    """
    def __init__(self, model: str = LLM_MODEL):
        self.model = model

    def generate(self, messages):
        """
        发送对话消息给 OpenRouter API 并返回 LLM 的回复文本

        参数:
            messages: 一个 list,每个元素都是形如 {'role': role, 'content': content} 的字典

        返回:
            LLM 返回的回复文本
        """
        request_body = {
            "model": self.model,
            "messages": messages
        }

        print(f"发送请求到 OpenRouter: {json.dumps(request_body, ensure_ascii=False)}")

        response = requests.post(
            OPENROUTER_API_URL,
            headers=OPENROUTER_HEADERS,
            json=request_body
        )

        if response.status_code != 200:
            print(f"OpenRouter API 错误: {response.status_code}")
            print(f"错误详情: {response.text}")
            raise Exception(f"OpenRouter API 返回错误: {response.status_code}")

        response_json = response.json()
        print(f"OpenRouter API 响应: {json.dumps(response_json, ensure_ascii=False)}")

        # 提取 LLM 响应文本
        try:
            content = response_json['choices'][0]['message']['content']
            return content
        except KeyError:
            raise Exception("无法从 OpenRouter 响应中提取内容")


# 如果需要独立测试该模块,可以在此进行简单的测试
if __name__ == "__main__":
    # 示例系统提示和用户输入
    messages = [
        {"role": "system", "content": "你是一个智能助手,可以帮助查询天气信息。"},
        {"role": "user", "content": "请告诉我北京今天的天气情况。"}
    ]

    llm = OpenRouterLLM()
    try:
        result = llm.generate(messages)
        print("LLM 返回结果:")
        print(result)
    except Exception as e:
        print(f"调用 OpenRouter 时发生异常: {e}")

MCP Client

这里的MCP Client,使用Server-Side Event(SSE)方式进行连接(题外话,MCP协议使用SSE协议作为默认远程协议稍微有点奇怪,听说后续迭代会考虑HTTP Streaming以及JSONRPC over HTTP2的方式)。

这里我们在main测试代码中,尝试列出所有可用的Tool与Resource,并尝试调用Tool,结果如图,可以看到能够展示出MCP Server中定义的Tool。

复制代码
# mcp_client_demo.py
import asyncio
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client

class WeatherMCPClient:
    def __init__(self, server_url="http://127.0.0.1:1234/sse"):
        self.server_url = server_url
        self._sse_context = None
        self._session = None

    async def __aenter__(self):
        # 创建 SSE 通道
        self._sse_context = sse_client(self.server_url)
        self.read, self.write = await self._sse_context.__aenter__()

        # 创建 MCP 会话
        self._session = ClientSession(self.read, self.write)
        await self._session.__aenter__()
        await self._session.initialize()

        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self._session:
            await self._session.__aexit__(exc_type, exc_val, exc_tb)
        if self._sse_context:
            await self._sse_context.__aexit__(exc_type, exc_val, exc_tb)

    async def list_tools(self):
        return await self._session.list_tools()

    async def list_resources(self):
        return await self._session.list_resources()

    async def call_tool(self, name, arguments):
        return await self._session.call_tool(name, arguments)


async def main():
    async with WeatherMCPClient() as client:
        print("✅ 成功连接 MCP Server")

        tools = await client.list_tools()

        print("\n🛠 可用工具:")
        print(tools)

        resources = await client.list_resources()
        print("\n📚 可用资源:")
        print(resources)

        print("\n📡 调用 WeatherTool 工具(city=北京)...")
        result = await client.call_tool("get_weather", {"city": "北京"})

        print("\n🎯 工具返回:")
        for item in result.content:
            print(" -", item.text)

if __name__ == "__main__":
    asyncio.run(main())

MCP Host

MCP host的角色也就是我们需要嵌入AI的应用,可以是一个程序,可以是一个CRM系统,可以是一个OA,MCP Host包含MCP Client,用于集成LLM与Tool,MCP Host之外+Tool+大模型,共同构成了一套基于AI的系统,现在流行的说法是AI Agent(中文翻译:AI智能体?)

MCP Host代码中步骤注释,与图1中的整体MCP流程对齐。

复制代码
import asyncio
import json
import re
from llm_router import OpenRouterLLM
from mcp_client_demo import WeatherMCPClient


def extract_json_from_reply(reply: str):
    """
    提取 LLM 返回的 JSON 内容,自动处理 markdown 包裹、多余引号、嵌套等。
    支持 string 或 dict 格式。
    如果无法解出 dict,则返回原始 string。
    """
    # 如果已经是 dict,直接返回
    if isinstance(reply, dict):
        return reply

    # 清除 markdown ```json ``` 包裹
    if isinstance(reply, str):
        reply = re.sub(r"^```(?:json)?|```$", "", reply.strip(), flags=re.IGNORECASE).strip()

    # 最多尝试 3 层 json.loads 解码
    for _ in range(3):
        try:
            parsed = json.loads(reply)
            if isinstance(parsed, dict):
                return parsed
            else:
                reply = parsed  # 如果解出来还是 str,继续下一层
        except Exception:
            break

    # 如果最终不是 dict,返回原始字符串(表示是普通答复)
    return reply


llm = OpenRouterLLM()


async def main():
    # === 初始化 MCP 客户端 ===
    client = WeatherMCPClient()
    await client.__aenter__()

    tools = await client.list_tools()
    resources = await client.list_resources()
    tool_names = [t.name for t in tools.tools]

    tool_descriptions = "\n".join(f"- {t.name}: {t.description}" for t in tools.tools)
    resource_descriptions = "\n".join(f"- {r.uri}" for r in resources.resources)

    while True:
        # === Step 1. 用户 → MCP主机:提出问题 ===
        user_input = input("\n请输入你的问题(输入 exit 退出):\n> ")
        if user_input.lower() in ("exit", "退出"):
            break

        # 构造系统提示 + 工具说明
        system_prompt = (
            "你是一个智能助手,拥有以下工具和资源可以调用:\n\n"
            f"🛠 工具列表:\n{tool_descriptions or '(无)'}\n\n"
            f"📚 资源列表:\n{resource_descriptions or '(无)'}\n\n"
            "请优先调用可用的Tool或Resource,而不是llm内部生成。仅根据上下文调用工具,不传入不需要的参数进行调用\n"
            "如果需要,请以 JSON 返回 tool_calls,格式如下:\n"
            '{"tool_calls": [{"name": "get_weather", "arguments": {"city": "北京"}}]}\n'
            "如无需调用工具,返回:{\"tool_calls\": null}"
        )

        # === 构造 LLM 上下文消息 ===
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_input}
        ]

        final_reply = ""

        # === 循环处理 tool_calls,直到 LLM 给出最终 content 为止 ===
        while True:
            # === Step 2. MCP主机 → LLM:转发上下文 ===
            reply = llm.generate(messages)
            print(f"\n🤖 LLM 回复:\n{reply}")

            # === Step 3. 解析 JSON 格式回复(或普通字符串) ===
            parsed = extract_json_from_reply(reply)

            # === 如果是普通自然语言字符串,说明 LLM 已直接答复用户 ===
            if isinstance(parsed, str):
                final_reply = parsed
                break

            # === 如果是字典,判断是否包含工具调用 ===
            tool_calls = parsed.get("tool_calls")
            if not tool_calls:
                # LLM 给出普通答复结构(带 content 字段)
                final_reply = parsed.get("content", "")
                break

            # === 遍历 LLM 请求的工具调用列表 ===
            for tool_call in tool_calls:
                # === Step 4. LLM → MCP客户端:请求使用工具 ===
                tool_name = tool_call["name"]
                arguments = tool_call["arguments"]

                if tool_name not in tool_names:
                    raise ValueError(f"❌ 工具 {tool_name} 未注册")

                # === Step 5. MCP客户端 → MCP服务器:调用工具 ===
                print(f"🛠 调用工具 {tool_name} 参数: {arguments}")
                result = await client.call_tool(tool_name, arguments)

                # === Step 8. MCP服务器 → MCP客户端:返回结果 ===
                tool_output = result.content[0].text
                print(f"📦 工具 {tool_name} 返回:{tool_output}")

                # === Step 9. MCP客户端 → LLM:提供工具结果 ===
                messages.append({
                    "role": "tool",
                    "name": tool_name,
                    "content": tool_output
                })

            # Step 10: 再次调用 LLM,进入下一轮(可能再次产生 tool_calls)

        # === Step 11. MCP主机 → 用户:最终结果答复 ===
        print(f"\n🎯 最终答复:{final_reply}")

    await client.__aexit__(None, None, None)


if __name__ == "__main__":
    asyncio.run(main())

用户提问

DEMO的交互方式是一个简单的Chatbox。假设用户在聊天界面的输入框里敲下:"上海的天气如何" 。此时,用户的问题通过 MCP 主机(MCP Host) 被发送给大模型。

MCP Host 可以是一个浏览器前端、桌面应用,也可以只是后端的一段代码。在这个场景里,它主要负责收集用户输入并与LLM通信。

对应流程图1中的步骤1:提出问题 与步骤2:转发问题。

LLM 推理:是否需要外部Tool配合

收到用户提问后,MCP 主机(Host)负责将用户提问解析并附加上下文后转发给大模型。主要取决于系统设计的智能程度、工具丰富度,以及 LLM 的能力边界。通常可以是一段静态的提示词,或者从上下文中获取动态的提示词,也可以是通过一些外部API获取数据生成提示词,这并不是本文的重点,本文通过简单的静态提示词进行。

本DEMO的静态提示词如下:

复制代码
        # 构造系统提示 + 工具说明
        system_prompt = (
            "你是一个智能助手,拥有以下工具和资源可以调用:\n\n"
            f" 工具列表:\n{tool_descriptions or '(无)'}\n\n"
            f" 资源列表:\n{resource_descriptions or '(无)'}\n\n"
            "请优先调用可用的Tool或Resource,而不是llm内部生成。仅根据上下文调用工具,不传入不需要的参数进行调用\n"
            "如果需要,请以 JSON 返回 tool_calls,格式如下:\n"
            '{"tool_calls": [{"name": "get_weather", "arguments": {"city": "北京"}}]}\n'
            "如无需调用工具,返回:{\"tool_calls\": null}"
        )

注意:MCP 协议与传统 Function Calling 最大的区别在于:工具调用的时机、选择和参数完全由大模型基于上下文和系统提示词自主推理决策,而不是由应用层预先决定调用哪个工具。这种模型主导的调用方式(model-driven invocation)体现了 Agent 思维,MCP 由此成为构建AI Agent 的关键协议基础之一。

LLM 此时会分析用户的问题:"上海的天气如何?" 如果这是一个普通常识性问题,LLM 也许可以直接作答;但这里问的是实时天气,超出了模型自身知识(训练数据可能并不包含最新天气)。此时的 LLM 就像进入一个未知领域,它明确知道需要外部信息的帮助来解答问题。在DEMO的会话开始时,MCP 主机已经通告诉 LLM 可以使用哪些工具(例如提供天气的工具叫 "get_weather")。因此 LLM 判断:需要触发一次 Tool Call 来获取答案。

在代码实现上,LLM 模型被提示可以调用工具。当模型决定调用时(对应图1中的步骤4),会生成一段特殊的结构化信息(通常是 JSON)。比如我们的 LLM 可能返回如下内容而不是直接答案:

复制代码
{
  "tool_calls": [
    {
      "name": "get_weather",
      "arguments": {
        "city": "上海"
      }
    }
  ]
}

上面 JSON 表示:LLM请求使用名为"get_weather"的工具,并传递参数城市为"上海"。MCP 主机的 模块会检测到模型输出的是一个 Tool Call 请求 而非普通文本答案------通常通过判断返回是否是合法的 JSON、且包含预期的字段来确认。这一刻,LLM 相当于对主机说:"我需要用一下get_weather工具帮忙查一下上海天气的天气!"

日志中可以看到这一决策过程:

复制代码
🤖 LLM 回复:
{"tool_calls": [{"name": "get_weather", "arguments": {"city": "上海"}}]}
🛠 调用工具 get_weather 参数: {'city': '上海'}

如果 LLM 能直接回答问题(不需要工具),那么它会返回纯文本,MCP 主机则会直接将该回答返回给客户端,完成整个流程。而在本例中, 需要外部Tool获取数据。

Tool Call 发起与数据获取

LLM 向MCP Host发起 Tool Call 请求(对应图1中的步骤5),MCP 主机现在扮演起"信使"的角色,通过MCP Client将这个请求转交给对应的 MCP 服务器。MCP 服务器可以看作提供特定工具或服务的后端,比如一个天气信息服务。我们在示例代码 mcp_host_demo.py 中,会调用 MCP 客户端模块(与 MCP Server 通信的组件)发送请求,例如:result = mcp_client.call_tool(tool_name, args)。

此时日志可能会出现:

复制代码
🛠 调用工具 get_weather 参数: {'city': '上海'}

MCP 服务器收到请求后,开始处理实际的数据查询。在我们的例子中,MCP Server 内部知道 get_weather如何获取天气数据(本例中是硬编码,但通常应该是一个外部API接口)。它会向数据源(可能是一个实时天气数据库或API)请求上海当前的天气。示例代码 mcp_server_demo.py 中定义了 硬编码的get_weather 工具的实现(因此也就忽略了图1中从mcp server与后端数据源的交互,步骤6与步骤7)

接下来,MCP 服务器将拿到的数据打包成结果返回。根据 MCP 协议规范,结果通常也用 JSON 表示,这里使用MCP Python SDK解析后的字符串结果:

复制代码
result = await client.call_tool(tool_name, arguments)

# === Step 8. MCP服务器 → MCP客户端:返回结果 ===
tool_output = result.content[0].text
print(f"📦 工具 {tool_name} 返回:{tool_output}")

在控制台日志里,我们可以看到:

复制代码
工具 get_weather 返回:上海:多云,27°C

可以看到,MCP 服务器既完成了实际的数据获取,又把结果封装成统一格式返回给MCP Host。整个过程对于 LLM 和客户端来说是透明的:他们不需要关心天气数据具体来自哪个数据库或API,只需通过 MCP 协议与服务器交互即可。这体现了 MCP 模块化的设计理念------Tool的实现细节被封装在MCP Server中,对外提供标准接口。

结果返回与答案生成

现在MCP 主机从 MCP 服务器拿到了工具调用结果,接下来要做的是把结果交还给最初发起请求的 LLM,让它完成最终答案生成。

在我们的示例中,MCP 主机收到了 get_weather 的结果 JSON。MCP 主机会将该结果作为新的输入提供给 LLM。常见做法是将工具返回的结果附加到发送给LLM的对话中:

复制代码
{
  "model": "qwen/qwen2.5-vl-32b-instruct:free",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能助手,拥有以下工具和资源可以调用:\n\n🛠 工具列表:\n- get_weather: 获取指定城市的天气信息\n- suggest_activity: 根据天气描述推荐适合的活动\n\n📚 资源列表:\n(无)\n\n请优先调用可用的Tool或Resource,而不是llm内部生成。仅根据上下文调用工具,不传入不需要的参数进行调用\"北京\"}}]}\n如无需调用工具,返回:{\"tool_calls\": null}"
    },
    {
      "role": "user",
      "content": "上海的天气如何?"
    },
    {
      "role": "tool",
      "name": "get_weather",
      "content": "上海:多云,27°C"
    }
  ]
}

注意到新增的role: tool,这代表工具返回的信息作为上下文提供给LLM。

现在LLM 得到真实的天气数据后,拥有足够的数据,可以给出用户想要的答复了。对于用户问的"现在上海的天气怎么样?",模型现在知道上海天气晴朗,27°C左右。它组织语言,将信息融入自然的回答中。例如,模型产出:"上海的天气是多云,温度为27°C。根据当前天气条件,建议您进行室内活动。"

MCP 主机接收到来自 LLM 的最终回答文本后,会将其发送回先前等待的 MCP 客户端。客户端则将答案显示给用户。至此,一次完整的问答闭环结束,用户收到满意的答复,而背后经过的一系列 Tool Call 流程对用户来说几乎无感

在用户看来,聊天对话可能长这样:

复制代码
用户:上海的
助手:上海的天气是多云,温度为27°C。根据当前天气条件,建议您进行室内活动。

小结

通过上述实例,我们能直观感受到 MCP 架构在设计上的独特优势。它明确了 LLM 应用中的职责划分,让语言理解与工具调用两个不同的职责有效解耦,实现了更高的系统灵活性:

  • 模块化易扩展:添加新的工具服务只需实现一个独立的 MCP Server 即可,完全不需要改动 LLM 本身代码。无论是新增股票信息、日程安排或是其他功能,只需符合 MCP 协议标准,新服务即可迅速上线。
  • 接口统一标准化:MCP 清晰定义了请求和响应的标准化格式,极大降低了接入新工具的成本。开发者无需再为每种工具分别设计集成逻辑,统一 JSON Schema 接口,使得调试和维护更加直观、高效。
  • 实时能力增强:MCP 使 LLM 可以实时获取外部信息,突破模型训练数据的时效限制。诸如天气、新闻、股票行情甚至实时数据库查询等需求,都能轻松满足,从而大幅提升模型的实用性。
  • 安全控制精细化:由于工具调用被隔离在独立的 MCP Server 中,开发者可针对具体工具执行细粒度的权限和安全管理,有效避免了 LLM 直接运行任意代码的风险。
  • 故障易于追踪处理:错误消息通过标准协议明确返回,方便 LLM 做出合适的错误处理与用户反馈,有效提升用户体验及系统稳定性。

此外,MCP 未来还有许多潜在的拓展方向,例如支持多步工具链调用,使得 LLM 可以高效完成更复杂的任务;或者实现动态的工具发现与调用机制,让 LLM 能够根据实际需求自主选择工具。