1. 项目概述

最近在折腾AI Agent开发,发现一个挺头疼的问题:不同的大模型厂商,比如OpenAI、Anthropic、DeepSeek,它们的Function Calling格式都不太一样。更麻烦的是,各种外部工具和API的封装方式也是五花八门,每次想给Agent加个新能力,都得重新写一遍适配代码,效率实在太低。

直到我接触到了MCP(Model Context Protocol),感觉像是找到了救星。简单来说,MCP就像是为AI世界定义了一个“USB-C”接口。它把大模型调用工具的流程标准化了,无论是哪种模型,哪种工具,只要遵循MCP协议,就能即插即用。这大大简化了Agent的扩展和集成工作。

这个项目,就是我基于Python,从零开始构建一个MCP Server和Client的实战记录。我实现了一个具体的功能:让AI能够查询市面上主流Agent框架(比如LangChain、LlamaIndex、AutoGen等)的技术文档。Server端支持Stdio(本地)和SSE(远程)两种传输协议,Client端则演示了如何通过OpenAI API来调用这些工具。如果你也在做AI应用开发,或者对如何让大模型更“懂”你的业务数据感兴趣,这篇内容应该能给你一些直接的参考。

2. MCP协议核心价值与设计思路

2.1 为什么我们需要MCP?

在没有MCP之前,AI调用外部功能主要依靠各厂商自家的Function Calling机制。这带来了两个核心痛点:

首先是格式不统一。 OpenAI有OpenAI的function calling格式,Anthropic的Claude有它自己的工具调用格式,国内的一些大模型可能又是另一套。这意味着,如果你开发了一个工具,想让它同时被GPT-4和Claude使用,你就得为每个模型单独写一套适配层。这不仅仅是多写几行代码的问题,更是维护的噩梦。

其次是工具封装混乱。 一个工具,比如“查询数据库”,它的输入参数、输出格式、错误处理,都没有一个社区公认的标准。A开发者可能返回JSON,B开发者可能返回纯文本。当你的Agent需要集成几十个这样的工具时,光是把这些各异的接口“翻译”成模型能理解的样子,就是一项巨大的工程。

MCP的出现,就是为了解决这两个问题。它定义了一套标准的、与模型无关的协议,用于在AI模型(Client)和工具、数据源(Server)之间进行通信。你可以把MCP Server想象成一个标准的“插座”,而MCP Client就是“插头”。只要插头和插座都符合MCP规范,不管背后的电器(模型)和电源(工具)是什么品牌,都能正常工作。

2.2 项目目标与架构选型

我这个项目的目标很明确:构建一个能查询特定技术文档的MCP Server,并配套一个能调用它的Client。我选择了Python生态,主要是因为 mcp 这个官方SDK对Python的支持非常友好,能极大降低开发门槛。

在架构上,我决定同时实现两种传输协议,以适应不同的部署场景:

  1. Stdio(标准输入输出)协议 :这是为本地开发或单机部署设计的。Server作为一个独立的进程启动,通过标准输入(stdin)接收请求,通过标准输出(stdout)返回响应。它的优点是简单、零网络依赖,非常适合集成到Cursor、Cline这类本地IDE插件中。
  2. SSE(Server-Sent Events)协议 :这是为远程服务设计的。基于HTTP长连接,Server可以部署在云服务器上,多个Client可以通过网络连接上来。这为构建集中式的工具服务、微服务架构提供了可能。

工具本身的功能设计为:接收用户关于某个Agent框架的技术问题(query)和指定的框架名称(library),然后自动去该框架的官方文档站点进行站内搜索,抓取相关页面的文本内容并返回。这样,AI在回答技术问题时,就能直接引用最新、最准确的官方文档,而不是依赖可能过时或不全的预训练知识。

3. 环境准备与项目初始化

3.1 包管理工具UV的安装

项目的第一步是搭建开发环境。我强烈推荐使用 uv 作为Python的包管理器和项目工具链。它由Astral团队(Ruff的创建者)开发,速度极快,并且一体化地解决了虚拟环境、依赖安装和脚本运行的问题,体验比传统的 pip + venv 组合要好得多。

安装过程非常简单:

对于MacOS或Linux系统,打开终端,运行以下命令:

curl -LsSf https://astral.sh/uv/install.sh | sh

这条命令会从Astral的官方服务器下载安装脚本并执行。安装完成后,通常需要重启终端或者执行 source ~/.bashrc (或 source ~/.zshrc )来让 uv 命令生效。

对于Windows系统,建议在PowerShell(管理员模式)中运行:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

命令中的 -ExecutionPolicy ByPass 是为了允许执行远程脚本, irm Invoke-RestMethod 的别名,用于下载安装脚本, iex Invoke-Expression 的别名,用于执行下载的脚本。

注意 :安装完成后,可以通过运行 uv --version 来验证是否安装成功。如果系统提示找不到命令,请检查你的终端配置文件(如 .bashrc , .zshrc ),确保UV的安装路径(通常是 $HOME/.cargo/bin $HOME/.local/bin )已经添加到 PATH 环境变量中。

3.2 创建项目与核心依赖安装

环境准备好后,就可以创建我们的MCP项目了。

首先,创建一个项目目录并初始化:

# 使用uv初始化一个名为mcp-docs-server的新项目
uv init mcp-docs-server
cd mcp-docs-server

uv init 命令会创建一个包含基本项目结构(如 pyproject.toml 文件)的目录。

接着,创建并激活Python虚拟环境。UV使得这一步非常优雅:

# 创建虚拟环境,UV会默认使用.venv作为环境目录名
uv venv
# 激活虚拟环境
# 在MacOS/Linux上:
source .venv/bin/activate
# 在Windows上:
.venv\Scripts\activate

激活后,你的命令行提示符前通常会显示 (.venv) ,表示你已经在这个独立的Python环境中了。

现在安装项目所需的核心依赖:

uv add "mcp[cli]" httpx beautifulsoup4 python-dotenv uvicorn

让我解释一下每个包的作用:

  • mcp[cli] :这是MCP协议的官方Python SDK。 [cli] 是一个“额外”依赖,它包含了运行MCP命令行工具所需的包,对于开发和调试非常有用。
  • httpx :一个现代、异步的HTTP客户端库。我们将用它来发起网络请求,例如调用搜索API和抓取网页。它比传统的 requests 库对异步编程的支持更好。
  • beautifulsoup4 :强大的HTML解析库。当抓取到文档网页后,我们需要用它来提取纯净的文本内容,剔除HTML标签、脚本和样式。
  • python-dotenv :用于从 .env 文件加载环境变量。像API密钥这样的敏感信息,我们绝不会硬编码在代码里。
  • uvicorn :一个轻量级、快速的ASGI服务器。它是运行我们基于SSE协议的MCP Server所必需的。

最后,创建我们的主服务器文件:

touch main.py

至此,一个干净的、依赖明确的项目骨架就搭建完成了。 pyproject.toml 文件记录了所有依赖,团队其他成员或者部署时,只需要 uv sync 一下就能复现完全一致的环境。

4. 核心工具函数实现与原理剖析

4.1 定义文档源与搜索策略

工具的核心能力是“精准搜索特定框架的文档”。我们不能让AI去全网搜索,那样噪音太多。正确的做法是进行“站内搜索”(site search)。这就需要我们预先知道每个框架官方文档的域名。

我在代码中定义了一个字典 docs_urls 来维护这个映射关系:

docs_urls = {
    "langchain": "python.langchain.com/docs",
    "llama-index": "docs.llamaindex.ai/en/stable",
    "autogen": "microsoft.github.io/autogen/stable",
    "agno": "docs.agno.com",
    "openai-agents-sdk": "openai.github.io/openai-agents-python",
    "mcp-doc": "modelcontextprotocol.io",
    "camel-ai": "docs.camel-ai.org",
    "crew-ai": "docs.crewai.com"
}

为什么这么设计?

  1. 键(Key)是框架的标识符 :如 langchain llama-index 。这个标识符应该简单、无歧义,并且最好和框架的通用称呼一致,方便用户记忆和输入。
  2. 值(Value)是文档的站点限定符 :格式通常是 {域名}{可能的具体路径} 。例如 python.langchain.com/docs ,这将在后续构建搜索查询时,作为 site: 参数的值,确保搜索引擎只在该域名及其子路径下查找。

实操心得 :维护这个映射表是个持续的过程。框架的文档地址可能会变(比如从 docs.llamaindex.ai 迁移到 docs.llamaindex.com ),或者有新的框架需要支持。一个好的实践是将这个字典放在一个单独的配置文件(如 config.py )或甚至一个小型数据库中,方便动态更新,而无需修改核心代码。

4.2 集成搜索引擎API

为了执行搜索,我们需要一个搜索引擎。自己从零搭建一个爬虫和索引系统不现实,所以我选择了 Serper API 。它是一个专门为AI应用设计的谷歌搜索API,价格低廉,响应速度快,并且返回结构化的JSON数据,非常适合程序处理。

使用前,你需要去 Serper官网 注册一个账号,获取免费的API额度(每天足够用于开发和测试)。拿到API Key后,在项目根目录创建一个 .env 文件来保存它:

# .env 文件
SERPER_API_KEY=你的_serper_api_key_在这里
OPENAI_API_KEY=你的_openai_api_key_在这里 # Client端会用到
OPENAI_BASE_URL=https://api.openai.com/v1 # 如果你用的是官方OpenAI

重要安全提示 :务必把 .env 文件添加到 .gitignore 中,避免将API密钥意外提交到公开的代码仓库。

搜索函数 search_web 的实现如下:

import json
import os
import httpx

SERPER_URL = "https://google.serper.dev/search"

async def search_web(query: str) -> dict | None:
    # 构造请求体,这里我限制返回3条结果,平衡信息量和响应速度
    payload = json.dumps({"q": query, "num": 3})

    headers = {
        "X-API-KEY": os.getenv("SERPER_API_KEY"), # 从环境变量读取密钥
        "Content-Type": "application/json",
    }

    async with httpx.AsyncClient() as client:
        try:
            # 发起异步POST请求,设置30秒超时
            response = await client.post(
                SERPER_URL, headers=headers, data=payload, timeout=30.0
            )
            response.raise_for_status() # 如果HTTP状态码不是2xx,抛出异常
            return response.json() # 返回解析后的JSON数据
        except httpx.TimeoutException:
            # 处理超时,返回一个空结果结构,避免上层函数崩溃
            return {"organic": []}
        except Exception as e:
            # 可以在这里添加更细致的错误日志
            print(f"Search error: {e}")
            return {"organic": []}

关键点解析

  • 异步(async/await) :使用 async def await 是现代Python网络编程的推荐做法。它允许服务器在等待网络I/O(如API响应)时去处理其他请求,极大提高了并发性能。我们的MCP Server底层也是异步的,所以工具函数也必须是异步的。
  • 超时处理 :网络请求永远不可靠。设置 timeout=30.0 是必须的,防止一个慢速请求阻塞整个服务器。超时后,我们返回一个空列表,让工具优雅降级。
  • 错误处理 response.raise_for_status() 会在API返回错误(如401密钥无效、429频率限制)时抛出 HTTPStatusError 。在更完善的生产代码中,应该捕获这个异常并返回更友好的错误信息给调用方。

4.3 网页内容抓取与清洗

搜索API返回的是链接和摘要,我们需要进一步抓取目标网页的完整内容。这里用到了 httpx BeautifulSoup

from bs4 import BeautifulSoup

async def fetch_url(url: str) -> str:
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, timeout=30.0)
            # 使用html.parser解析器,它是Python标准库的一部分,无需额外依赖
            soup = BeautifulSoup(response.text, "html.parser")
            # 获取所有文本,并自动合并连续的空白字符
            text = soup.get_text(separator=' ', strip=True)
            return text
        except httpx.TimeoutException:
            return "[Error: Timeout while fetching the page]"
        except Exception as e:
            return f"[Error: Failed to fetch page - {str(e)}]"

内容清洗的注意事项

  • soup.get_text(separator=' ', strip=True) :这个调用会移除所有HTML标签,只保留文本。 separator=' ' 确保原本被标签分隔的文本用一个空格连接,而不是挤在一起。 strip=True 会去掉文本开头和结尾的空白。
  • 局限性 :这种方法会丢失所有的格式(如加粗、标题层级)和结构(如代码块、表格)。对于技术文档,这可能会影响可读性。更高级的方案可以尝试只提取 <article> <main> 标签内的内容,或者使用专门的文档解析库(如 readability trafilatura ),但复杂度会大大增加。当前方案在简单性和效果上取得了不错的平衡。
  • 礼貌抓取 :在循环中抓取多个URL时,最好在请求间添加短暂的延迟(如 await asyncio.sleep(1) ),并设置合理的 User-Agent 头,以示对目标网站的尊重,避免被反爬机制屏蔽。

4.4 组装MCP工具

最后,我们将上述组件组装成一个MCP工具。 mcp 库的 @tool() 装饰器是这个过程的核心。

from mcp import tool

@tool()
async def get_docs(query: str, library: str) -> str:
    """
    搜索给定查询和库的最新文档。
    支持 langchain、llama-index、autogen、agno、openai-agents-sdk、mcp-doc、camel-ai 和 crew-ai。

    参数:
    query: 要搜索的查询 (例如 "如何创建Agent")
    library: 要搜索的库 (例如 "langchain")

    返回:
    文档中的文本
    """
    # 1. 校验库是否支持
    if library not in docs_urls:
        raise ValueError(f"Library '{library}' is not supported. Supported libraries are: {list(docs_urls.keys())}")

    # 2. 构建站内搜索查询
    site_domain = docs_urls[library]
    search_query = f"site:{site_domain} {query}"
    
    # 3. 执行搜索
    results = await search_web(search_query)
    if not results.get("organic"):
        return f"No documentation found for '{query}' in {library}."

    # 4. 并发抓取结果页面内容
    fetched_texts = []
    for result in results["organic"]:
        link = result["link"]
        text = await fetch_url(link)
        # 简单过滤,避免返回完全空或错误的内容
        if text and len(text) > 50 and not text.startswith("[Error"):
            fetched_texts.append(f"--- From: {link} ---\n{text}\n")
    
    # 5. 合并并返回结果
    if not fetched_texts:
        return f"Found links but failed to retrieve meaningful content for '{query}' in {library}."
    
    # 限制总返回长度,避免超出模型上下文限制
    combined_text = "\n".join(fetched_texts)
    # 简单截断,生产环境可以考虑更智能的摘要
    max_length = 8000
    if len(combined_text) > max_length:
        combined_text = combined_text[:max_length] + "\n\n[Content truncated due to length limit]"
    
    return combined_text

工具设计要点

  • 文档字符串(Docstring)至关重要 :大模型(Client)正是通过这个文档字符串来理解这个工具是干什么的、需要什么参数。描述必须清晰、准确。参数名和描述要对应。
  • 错误处理与用户体验 :工具不能因为一个链接失效或搜索无结果就崩溃。我们进行了多层校验和友好的错误信息返回。例如,当库不支持时,抛出一个清晰的 ValueError ,并列出所有支持的库,这对调试非常有帮助。
  • 输出长度管理 :大模型有上下文窗口限制。无节制地返回抓取到的所有文本,很快就会撑爆上下文。这里我设置了一个简单的截断机制(8000字符)。更优的方案是引入文本摘要(Summarization)步骤,或者使用嵌入模型(Embedding)进行语义检索,只返回最相关的片段。

5. 构建Stdio协议的MCP Server

5.1 Server核心代码实现

有了工具函数,构建Server就水到渠成了。Stdio协议的Server是最简单的一种,它直接运行一个进程,通过标准输入输出流与Client通信。

# main_stdio.py
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
import httpx
import json
import os
from bs4 import BeautifulSoup

# 加载环境变量
load_dotenv()

# 创建FastMCP实例,给它起个名字
mcp = FastMCP("AgentDocsServer")

# 这里省略之前定义的 docs_urls, SERPER_URL, search_web, fetch_url 等变量和函数...

# 使用 mcp 实例的 tool 装饰器注册工具
@mcp.tool()
async def get_docs(query: str, library: str):
    """
    搜索给定查询和库的最新文档。
    支持 langchain、llama-index、autogen、agno、openai-agents-sdk、mcp-doc、camel-ai 和 crew-ai。

    参数:
    query: 要搜索的查询 (例如 "React Agent")
    library: 要搜索的库 (例如 "agno")

    返回:
    文档中的文本
    """
    # 工具实现逻辑与上一节完全相同
    if library not in docs_urls:
        raise ValueError(f"Library {library} not supported by this tool")
    query = f"site:{docs_urls[library]} {query}"
    results = await search_web(query)
    if len(results["organic"]) == 0:
        return "No results found"
    text = ""
    for result in results["organic"]:
        text += await fetch_url(result["link"])
    return text

# 程序入口
if __name__ == "__main__":
    # 以 stdio 传输模式运行服务器
    mcp.run(transport="stdio")

代码非常简洁。核心是 FastMCP 类,它是对底层MCP Server的高级封装。我们用 @mcp.tool() 装饰器注册了 get_docs 函数。最后调用 mcp.run(transport="stdio") ,服务器就会启动并监听标准输入。

启动服务器:

# 确保在项目目录下,且虚拟环境已激活
uv run main_stdio.py

启动后,你会发现程序似乎“挂起”了,没有输出。这是正常的,因为它正在等待从 stdin 接收符合MCP协议的消息。

5.2 在IDE中配置与使用(Cursor/Cline)

Stdio Server的主要应用场景是与支持MCP的IDE(如Cursor、Cline)集成,让AI助手能直接调用我们自定义的工具。

在Cursor中配置:

  1. 在你的项目根目录下,创建一个名为 .cursor 的文件夹。
  2. .cursor 文件夹内,创建一个 mcp.json 文件。
  3. 编辑 mcp.json ,内容如下:
{
  "mcpServers": {
    "agent-docs-server": {
      "command": "uv",
      "args": [
        "--directory",
        "/你的/绝对/项目/路径/mcp-docs-server",
        "run",
        "main_stdio.py"
      ],
      "env": {
        "SERPER_API_KEY": "你的_serper_api_key"
      }
    }
  }
}

配置详解

  • command : 指定启动Server的命令。我们使用 uv
  • args : 传递给命令的参数。
    • --directory : 告诉 uv 在哪个目录下执行。 这里必须使用绝对路径
    • run main_stdio.py : 让 uv 运行我们的主脚本。
  • env : (可选)可以在这里直接设置环境变量,避免依赖外部的 .env 文件。对于团队共享配置或不想创建 .env 文件的情况很有用。
  1. 保存文件,重启Cursor(或重新加载窗口)。如果配置正确,你会在Cursor的Activity Bar(活动栏)看到一个“MCP”图标,点击可以看到 agent-docs-server 显示为已连接(绿色)。

在Cline中配置: Cline是VSCode的插件,配置方式类似,但配置文件的位置不同。

  1. 在VSCode中,打开设置( Ctrl+, Cmd+, )。
  2. 搜索 mcp ,找到 MCP Servers 配置项。
  3. 点击“在settings.json中编辑”,添加如下配置:
{
  "mcpServers": {
    "agent-docs-server": {
      "command": "uv",
      "args": [
        "--directory",
        "/你的/绝对/项目/路径/mcp-docs-server",
        "run",
        "main_stdio.py"
      ],
      "env": {
        "SERPER_API_KEY": "你的_serper_api_key"
      }
    }
  }
}

配置成功后的使用体验 : 配置成功后,你就可以在Cursor或Cline的聊天框中直接使用了。例如,你可以问:“用LangChain怎么创建一个能使用工具的Agent?” AI助手在理解你的问题后,会自动识别出需要调用 get_docs 工具,并传入 query="create tool using agent" library="langchain" 参数。随后,它会在后台启动(或连接)我们的Server,执行搜索和抓取,并将获取到的文档片段作为上下文来生成更精准的回答。

避坑指南 :最常见的配置失败原因是路径错误或环境问题。务必使用绝对路径。如果Server启动失败,可以尝试在终端手动运行 uv run main_stdio.py 命令,看是否有Python包导入错误或API密钥缺失的报错。此外,确保你的 uv python 都在系统PATH中,或者使用完整的可执行文件路径。

6. 构建SSE协议的MCP Server

6.1 SSE Server架构解析

Stdio协议适合本地集成,但如果我想把这个文档查询服务部署到云端,供多个客户端或者一个团队使用,就需要SSE协议了。SSE(Server-Sent Events)是一种基于HTTP的服务器向客户端推送数据的技术。在MCP中,它被用来建立Client和Server之间的双向通信通道。

与简单的Stdio Server相比,SSE Server需要处理HTTP请求、管理连接,因此代码结构稍复杂一些。我们需要一个Web服务器(这里用 uvicorn + starlette )来承载SSE端点。

# main_sse.py
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from mcp.server import Server
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.routing import Route, Mount
import uvicorn
import argparse
# 省略之前重复的导入和工具函数定义...

# 创建FastMCP实例和工具(与Stdio版本完全相同)
mcp = FastMCP("AgentDocsServer")
# ... 注册 get_docs 工具 ...

# 关键函数:创建Starlette应用,并将MCP Server绑定到SSE路由
def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
    """创建一个Starlette应用,用于通过SSE服务MCP Server。"""
    # 初始化SSE传输层
    sse_transport = SseServerTransport("/messages/")

    async def handle_sse(request: Request) -> None:
        """
        处理SSE连接请求。
        这个函数是SSE连接的生命周期管理器。
        """
        # 1. 建立SSE连接,获取读写流
        async with sse_transport.connect_sse(
                request.scope,     # 请求的ASGI scope
                request.receive,   # 用于接收客户端消息的通道
                request._send,     # 用于向客户端发送消息的通道
        ) as (read_stream, write_stream):
            # 2. 在这个连接上下文中,运行MCP Server的主循环
            await mcp_server.run(
                read_stream,   # Server从Client读取消息
                write_stream,  # Server向Client写入消息
                mcp_server.create_initialization_options(), # 初始化选项
            )
        # 3. 当连接断开(跳出with块),异步上下文管理器会自动清理资源

    # 定义路由:
    # - /sse: 客户端通过这个端点建立SSE长连接。
    # - /messages/: 客户端通过这个端点向服务器发送具体的请求消息。
    return Starlette(
        debug=debug,
        routes=[
            Route("/sse", endpoint=handle_sse),
            Mount("/messages/", app=sse_transport.handle_post_message),
        ],
    )

if __name__ == "__main__":
    # 获取底层的标准MCP Server对象
    mcp_server = mcp._mcp_server

    # 解析命令行参数,方便指定主机和端口
    parser = argparse.ArgumentParser(description='Run MCP SSE-based server')
    parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
    parser.add_argument('--port', type=int, default=8020, help='Port to listen on')
    args = parser.parse_args()

    # 创建Web应用
    starlette_app = create_starlette_app(mcp_server, debug=True)

    # 使用uvicorn启动ASGI服务器
    print(f"Starting MCP SSE server on http://{args.host}:{args.port}")
    uvicorn.run(starlette_app, host=args.host, port=args.port)

代码流程剖析

  1. 创建MCP Server FastMCP 创建了一个高级封装,我们通过 ._mcp_server 属性获取到底层的标准 Server 对象,供SSE传输层使用。
  2. 创建SSE传输层 SseServerTransport 是MCP SDK提供的,它封装了SSE协议与MCP消息格式之间的转换逻辑。它需要知道客户端发送消息的端点路径(这里是 /messages/ )。
  3. 定义SSE端点处理器 handle_sse 函数是核心。当一个客户端(如我们后面要写的Python Client)连接到 /sse 时,这个函数被调用。
    • sse_transport.connect_sse(...) :建立SSE连接,并返回一对异步流( read_stream , write_stream )。这相当于在HTTP长连接上建立了一个双向通信的“管道”。
    • await mcp_server.run(...) :MCP Server的主循环开始运行。它会从 read_stream 读取客户端发来的请求(如“调用get_docs工具”),处理请求(执行我们的工具函数),然后将结果通过 write_stream 写回给客户端。
  4. 创建Web应用并路由 :我们将 /sse 路径映射到 handle_sse 函数,将 /messages/ 路径挂载到 sse_transport.handle_post_message 。后者负责接收客户端通过HTTP POST发送的具体消息。
  5. 启动服务器 :使用 uvicorn 这个ASGI服务器来运行我们创建的Starlette应用。

启动SSE Server:

uv run main_sse.py --host 0.0.0.0 --port 8020

看到 Starting MCP SSE server on http://0.0.0.0:8020 的输出,说明服务器已经在8020端口监听所有网络接口的连接了。

6.2 部署与运维考量

将SSE Server部署到生产环境(如云服务器)时,需要考虑以下几点:

  1. 进程管理 :不能简单地用 uv run 在终端前台运行。需要使用进程管理器,如 systemd (Linux)、 supervisord pm2 ,来保证服务在后台稳定运行,崩溃后自动重启。

    # 一个简单的systemd服务文件示例 (/etc/systemd/system/mcp-docs.service)
    [Unit]
    Description=MCP Docs Search Server
    After=network.target
    
    [Service]
    Type=simple
    User=ubuntu
    WorkingDirectory=/path/to/your/mcp-docs-server
    Environment="PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin"
    Environment="SERPER_API_KEY=your_key_here"
    ExecStart=/home/ubuntu/.cargo/bin/uv run main_sse.py --host 127.0.0.1 --port 8020
    Restart=on-failure
    
    [Install]
    WantedBy=multi-user.target
    
  2. 网络与安全

    • --host 0.0.0.0 意味着监听所有IP,这在公网服务器上可能有风险。在生产环境,更安全的做法是绑定到 127.0.0.1 ,然后通过Nginx这样的反向代理对外暴露,Nginx可以处理SSL/TLS加密(HTTPS)、负载均衡和访问控制。
    • 考虑在Server端增加简单的认证,例如要求Client在请求头中携带一个API Token。
  3. 性能与扩展 :单个Python进程处理能力有限。如果工具调用很耗时(比如网络请求多),或者并发请求量大,需要考虑:

    • 使用 uvicorn --workers 选项启动多个工作进程。
    • 将工具函数中的IO密集型操作(如网络请求)放到异步任务队列(如 celery + redis )中执行,避免阻塞主事件循环。
    • 对搜索结果或抓取到的文档内容进行缓存,减少对搜索引擎和文档站点的重复请求。

7. 构建Python MCP Client

7.1 Client端的设计与连接

Server准备好了,我们需要一个能与之对话的Client。这个Client将扮演“大模型”的角色,负责理解用户意图,决定何时调用工具,并整合工具结果生成最终回复。这里我们用OpenAI的API来模拟这个大模型,但实际上任何能处理OpenAI格式的function calling的模型或框架都可以。

# client.py
import asyncio
import json
import os
import sys
from typing import Optional
from contextlib import AsyncExitStack

from mcp import ClientSession
from mcp.client.sse import sse_client
from openai import AsyncOpenAI
from dotenv import load_dotenv

load_dotenv()

class MCPClient:
    def __init__(self):
        self.session: Optional[ClientSession] = None
        self.exit_stack = AsyncExitStack() # 用于管理异步上下文
        self.openai = AsyncOpenAI(
            api_key=os.getenv("OPENAI_API_KEY"),
            base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
        )
        self._streams_context = None
        self._session_context = None

    async def connect_to_sse_server(self, server_url: str):
        """连接到运行SSE传输的MCP服务器"""
        print(f"Connecting to MCP server at {server_url}...")
        
        # 1. 建立SSE连接上下文
        self._streams_context = sse_client(url=server_url)
        streams = await self._streams_context.__aenter__()

        # 2. 创建MCP客户端会话上下文
        self._session_context = ClientSession(*streams)
        self.session = await self._session_context.__aenter__()

        # 3. 执行MCP初始化握手
        await self.session.initialize()

        # 4. 列出服务器提供的工具,验证连接
        response = await self.session.list_tools()
        tools = response.tools
        print(f"✓ Connected successfully!")
        print(f"Available tools: {[tool.name for tool in tools]}")

    async def cleanup(self):
        """清理资源,关闭连接"""
        print("\nCleaning up resources...")
        if self._session_context:
            await self._session_context.__aexit__(None, None, None)
        if self._streams_context:
            await self._streams_context.__aexit__(None, None, None)
        print("Cleanup complete.")

连接过程详解

  1. 初始化 :创建 AsyncOpenAI 客户端,用于后续与OpenAI API通信。
  2. sse_client(url) :这是MCP SDK提供的客户端SSE传输层工厂函数。它接收Server的SSE端点URL(例如 http://localhost:8020/sse ),并返回一个异步上下文管理器。进入这个上下文( __aenter__ )会建立HTTP长连接。
  3. ClientSession(*streams) :用上一步建立的连接流(一个读流,一个写流)创建MCP客户端会话。会话是进行所有MCP操作(如初始化、列出工具、调用工具)的入口。
  4. session.initialize() :执行MCP协议要求的初始化握手。Server和Client会交换版本、能力等信息。
  5. session.list_tools() :向Server请求当前可用的工具列表。这是验证连接是否正常、工具是否注册成功的好方法。

7.2 工具调用与对话循环

连接建立后,Client的核心工作就是处理用户查询,协调大模型和工具。

    async def process_query(self, user_query: str) -> str:
        """处理用户查询:使用OpenAI决定是否/如何调用工具,并整合结果。"""
        # 初始化对话历史
        messages = [{"role": "user", "content": user_query}]
        
        # 1. 获取Server端可用的工具列表,并格式化成OpenAI API要求的格式
        response = await self.session.list_tools()
        available_tools = []
        for tool in response.tools:
            # MCP工具描述转换为OpenAI function calling格式
            available_tools.append({
                "type": "function",
                "function": {
                    "name": tool.name,
                    "description": tool.description or f"Calls the {tool.name} tool.",
                    "parameters": tool.inputSchema # MCP工具本身就定义了输入参数的JSON Schema
                }
            })
        
        final_response_parts = []
        max_iterations = 5 # 防止无限循环
        iteration = 0

        while iteration < max_iterations:
            iteration += 1
            
            # 2. 调用OpenAI,传入当前对话历史和可用工具
            completion = await self.openai.chat.completions.create(
                model=os.getenv("OPENAI_MODEL", "gpt-3.5-turbo"),
                messages=messages,
                tools=available_tools if available_tools else None, # 只在第一轮或需要时传递?
                tool_choice="auto", # 让模型自己决定是否调用工具
            )

            assistant_message = completion.choices[0].message
            
            # 3. 判断模型是否决定调用工具
            if assistant_message.tool_calls:
                print(f"\n[Iteration {iteration}] Model decided to use tools.")
                for tool_call in assistant_message.tool_calls:
                    tool_name = tool_call.function.name
                    try:
                        tool_args = json.loads(tool_call.function.arguments)
                    except json.JSONDecodeError:
                        tool_args = {}
                        print(f"Warning: Failed to parse arguments for {tool_name}")

                    # 记录工具调用
                    final_response_parts.append(f"[Calling tool: {tool_name} with args: {tool_args}]")
                    
                    # 4. 执行实际的MCP工具调用
                    try:
                        result = await self.session.call_tool(tool_name, tool_args)
                        # result.content 是一个列表,通常第一个元素是文本
                        tool_result_text = result.content[0].text if result.content else "Tool executed with no text output."
                        print(f"Tool '{tool_name}' returned: {tool_result_text[:200]}...") # 打印前200字符
                    except Exception as e:
                        tool_result_text = f"Error calling tool {tool_name}: {str(e)}"
                        print(f"Tool call error: {tool_result_text}")
                    
                    # 5. 将工具调用和结果追加到对话历史,让模型进行下一轮思考
                    messages.append({
                        "role": "assistant",
                        "content": None,
                        "tool_calls": [tool_call]
                    })
                    messages.append({
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": tool_result_text
                    })
                    
                    final_response_parts.append(f"[Tool result]: {tool_result_text}")
                
                # 本轮有工具调用,继续循环,让模型基于工具结果生成最终回答
                continue 
            else:
                # 6. 模型没有调用工具,直接生成最终回答
                final_answer = assistant_message.content
                if final_answer:
                    final_response_parts.append(final_answer)
                break # 退出循环

        # 7. 返回整合后的最终文本
        return "\n\n".join(final_response_parts)

    async def chat_loop(self):
        """运行一个交互式聊天循环"""
        print("\n" + "="*50)
        print("MCP Client Started! Connect to your AI assistant.")
        print("Type your query (e.g., 'How to create an agent in LangChain?')")
        print("Type 'quit' or 'exit' to stop.")
        print("="*50)
        
        while True:
            try:
                user_input = input("\nYou: ").strip()
                if user_input.lower() in ['quit', 'exit', 'q']:
                    print("Goodbye!")
                    break
                    
                if not user_input:
                    continue
                    
                print("\nThinking...")
                response = await self.process_query(user_input)
                print(f"\nAssistant:\n{response}")
                
            except KeyboardInterrupt:
                print("\n\nInterrupted by user.")
                break
            except Exception as e:
                print(f"\nAn error occurred: {type(e).__name__}: {e}")

核心交互逻辑分析 : 这是一个典型的 多轮对话(Multi-turn) 工具调用(Tool Calling) 的循环:

  1. 准备工具列表 :将MCP Server提供的工具描述,转换成OpenAI API认识的 tools 参数格式。
  2. 调用大模型 :将用户问题 user_query 和工具列表 available_tools 发给OpenAI。 tool_choice="auto" 让模型自己判断是否需要调用工具。
  3. 解析模型决策 :检查 assistant_message.tool_calls 。如果非空,说明模型决定调用工具。
  4. 执行工具调用 :遍历每个工具调用指令,提取工具名和参数,然后通过 await self.session.call_tool(tool_name, tool_args) 真正地调用远端的MCP Server。这是Client和Server通信的关键一步。
  5. 更新对话历史 :这是实现多轮交互的关键。我们必须严格按照OpenAI的格式,将 assistant tool_calls 消息和 tool 角色(携带结果)的消息追加到 messages 列表末尾。这样,在下一次循环中,模型就能看到它之前决定调用工具,以及工具返回的结果,从而基于这些新信息生成最终回答或决定调用下一个工具。
  6. 生成最终回答 :当模型不再调用工具时( assistant_message.tool_calls 为空),其 content 就是最终答案。
  7. 循环控制 :添加了 max_iterations 限制,防止模型陷入无限调用工具的循环。

启动Client并连接我们的SSE Server:

# 假设SSE Server运行在本地8020端口
uv run client.py http://localhost:8020/sse

如果一切正常,你会看到连接成功和可用工具的提示,然后进入交互式聊天界面。你可以尝试输入:“用LangChain怎么创建一个能使用工具的Agent?” Client会协调OpenAI和我们的MCP Server,最终给出一个结合了实时文档搜索结果的回答。

7.3 错误处理与健壮性增强

上面的基础版本忽略了大量错误处理。一个健壮的Client应该考虑:

  1. 网络连接与重试 :SSE连接可能不稳定。需要添加重试逻辑,并在连接断开时尝试重新连接。
  2. 工具调用超时 session.call_tool 应该设置超时,防止某个工具执行时间过长卡死整个对话。
  3. 模型输出解析 :对模型返回的 tool_calls 参数进行更严格的校验,确保 name 在可用工具列表中, arguments 是合法的JSON。
  4. 上下文长度管理 :随着多轮对话进行, messages 历史会越来越长。需要设计策略来修剪或总结历史,避免超出模型的上下文窗口。
  5. 流式输出 :当前是等待所有工具调用和最终回答完成后再一次性输出。更好的体验是使用OpenAI的流式响应,让用户看到模型“思考”和工具调用的过程。

8. 常见问题、调试技巧与扩展方向

8.1 开发与调试中的常见问题

1. 连接失败: ConnectionRefusedError Cannot connect to host

  • 原因 :SSE Server没有运行,或者主机/端口不对。
  • 排查
    • 在服务器端,确认 uvicorn 已成功启动,并监听在正确的端口(如 0.0.0.0:8020 )。
    • 在客户端,检查URL是否正确(如 http://服务器IP:8020/sse )。
    • 检查防火墙设置,确保端口(如8020)对客户端开放。

2. MCP握手失败:初始化后没有列出工具

  • 原因 :Server端的工具没有正确注册,或者传输层协议有误。
  • 排查
    • 确保Server代码中使用了 @mcp.tool() 装饰器,并且工具函数是 async 的。
    • 检查Server启动日志,看是否有导入错误或运行时异常。
    • 对于Stdio Server,可以尝试用一个简单的测试Client(或使用 mcp CLI工具)来验证。例如,安装 mcp[cli] 后,可以运行: uvx mcp dev main_stdio.py ,这会启动一个开发服务器并提供一个简单的交互界面来测试工具。

3. 工具调用返回错误或超时

  • 原因 :工具函数内部出错(如API密钥无效、网络超时、网页结构变化导致解析失败)。
  • 排查
    • 在Server工具函数内部添加更详细的日志,打印关键步骤和错误信息。
    • 检查环境变量( SERPER_API_KEY )是否已正确设置并被Server进程读取。
    • 模拟工具调用:直接在Python REPL中导入并调用你的 search_web fetch_url 函数,看是否能正常工作。

4. Cursor/Cline中MCP Server显示未连接

  • 原因 :配置文件路径错误、命令执行失败、或环境变量缺失。
  • 排查
    • 绝对路径 :确认 mcp.json 中的 args 里的 --directory 参数是 绝对路径
    • 手动执行 :打开终端,切换到项目目录,手动执行配置文件中定义的命令(如 uv --directory /path run main.py ),看是否能成功启动Server并观察有无报错。
    • 查看日志 :Cursor和Cline通常有输出面板或日志文件,可以查看MCP连接的具体错误信息。

8.2 性能优化与扩展思路

当前实现是一个功能完整但基础的版本。在实际生产应用中,可以从以下几个方向进行优化和扩展:

1. 增加结果缓存 频繁搜索相同的查询是一种浪费。可以引入一个缓存层(如 redis diskcache ),将 (query, library) 作为键,将抓取到的文本作为值缓存起来,并设置一个合理的过期时间(例如1小时)。这能显著降低对Serper API的调用次数,提升响应速度。

2. 实现更智能的内容提取与摘要 当前的 fetch_url 函数只是提取了全部文本。对于技术文档,我们可能只关心与查询最相关的几个段落。可以: - 使用HTML解析器更精准地定位 <main> <article> 或包含特定CSS类的内容区域。 - 在抓取到全文后,使用嵌入模型(如OpenAI的 text-embedding-3-small )计算查询与文档各段落的相似度,只返回最相关的几个片段。 - 或者,直接使用大模型对抓取到的长文本进行摘要,再返回摘要结果。

3. 支持更多数据源和工具类型 MCP的魅力在于可扩展性。除了搜索网页文档,你可以轻松添加更多工具: - 数据库查询工具 :连接你的业务数据库,让AI能查询用户数据、订单状态等。 - 内部API工具 :封装公司内部的CRM、ERP等系统API。 - 代码仓库工具 :连接Git,让AI能搜索代码库、查看提交历史、甚至创建Pull Request。 - 文件系统工具 :在安全可控的前提下,让AI能读取指定目录下的文档、配置文件。

4. 构建更强大的Client端 当前的Client只是一个简单的演示。一个成熟的AI应用Client可能包含: - 多模型支持 :除了OpenAI,可以集成Anthropic Claude、Google Gemini、本地部署的Ollama模型等。 - 复杂的对话管理 :支持多轮对话、对话记忆、上下文窗口的滑动管理。 - 工具调用策略 :不仅仅是依赖模型决定,可以加入规则引擎,根据用户意图主动推荐或强制使用某些工具。 - 前端界面 :构建一个Web或桌面聊天界面,而不仅仅是命令行。

5. 安全与权限控制 当工具能力越来越强时,安全至关重要。 - Server端 :对传入的 query library 参数进行严格的验证和清洗,防止注入攻击。对可访问的 site: 域名进行白名单限制。 - Client-Server通信 :为SSE Server增加API密钥认证。使用HTTPS加密传输数据。 - 工具级权限 :可以为不同的工具或不同的用户设置访问权限。例如,查询内部数据库的工具只能由特定角色的用户触发。

这个项目从零开始,搭建了一个连接AI模型与外部知识(技术文档)的桥梁。通过实现MCP协议,我们不仅解决了一个具体问题,更掌握了一种标准化扩展AI能力的方法。无论是集成到日常使用的IDE中,还是作为微服务部署到云端,MCP都提供了一条清晰、规范的路径。希望这篇详细的实践记录,能帮助你快速上手MCP,并激发出更多将AI与具体业务场景结合的想法。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐