跳转至

别让你的SSE MCP服务卡住!一行 requests.post 引发的性能危机

问题出现

在使用 Python 开发基于 MCP 的 SSE 服务时,如果不小心写了同步阻塞的代码,就非常容易踩进 IO 阻塞的大坑。来看下面这段示例代码:

from mcp.server.fastmcp import FastMCP
from pydantic import Field
import time
import requests

# 初始化mcp服务
mcp = FastMCP("hello-mcp-server")

@mcp.tool(name="url_get", description="访问某个url")
def get_url(url: str = Field(..., description="将要访问的url")):
    r = requests.get(url)
    return r.text

@mcp.tool(name="计算加法", description="计算加法")
def add(a: int = Field(..., description="第一个数"), b: int = Field(..., description="第二个数")):
    return a + b

def run():
    mcp.run(transport="sse")

if __name__ == "__main__":
    run()

上述代码中的工具函数是同步的,requests.get 是一个阻塞操作。当你把它运行在 transport="sse" 的环境下时,问题就来了,如果此时有多个客户端连接着这个sse服务器,那么之后的工具调用则会全部阻塞卡住。

🔎 为什么以前没出问题?

我们再来看一个**正确写法的对比案例**,以往开发中通常会使用异步函数:

# 初始化mcp服务
mcp = FastMCP("hello-mcp-server")

# 定义工具
@mcp.tool(name="高德天气查询助手", description=".....")
async def query_logistics(city: str = Field(description="要查询天气的城市名称")) -> str:
    ...
    async with httpx.AsyncClient(headers=headers) as client:
        response = await client.get(url)
        ...

这段代码使用了 async def,并且所有 IO 操作(如网络请求)都是通过 await 调用异步库完成的,整个事件循环是不会被阻塞的。

而我们一开始的代码里,工具函数是同步的,加上 requests.get直接阻塞整个 worker

🚨 会导致什么后果?

当你的服务以 SSE(Server-Sent Events)形式运行,并被多个客户端同时连接时:

[!bug] 卡死 一旦有一个客户端调用了同步阻塞的函数(如 requests.get),所有其他客户端的请求也会被卡住!💥

这就是典型的 Python IO 阻塞问题 —— 单线程的事件循环被卡住了

⚙️ 问题是怎么发生的?

来看 mcp.run(transport="sse") 的底层逻辑,它最终启动了一个 Uvicorn HTTP 服务:

async def run_sse_async(self) -> None:
    """Run the server using SSE transport."""
    starlette_app = self.sse_app()

    config = uvicorn.Config(
        starlette_app,
        host=self.settings.host,
        port=self.settings.port,
        log_level=self.settings.log_level.lower(),
    )
    server = uvicorn.Server(config)
    await server.serve()

uvicorn 在运行 FastAPI(或其他 ASGI 框架)时,是**异步驱动的**。如果你在异步环境中使用了同步阻塞的代码(如 time.sleeprequests.post 等),会阻塞整个事件循环线程,使得当前 worker 无法处理任何其他请求 🧨。

✅ 如何解决?

在编写 MCP SSE 工具函数时,遵循以下原则即可避免阻塞:

  1. 总是使用 async def 定义工具函数

  2. 将所有 IO 操作改为异步非阻塞的形式

具体方法如下:

✅ 使用原生异步方法(推荐)

将同步调用改为响应的异步库调用,如 requests 改为 httpx 异步实现:

from mcp.server.fastmcp import FastMCP
from pydantic import Field
import asyncio

# 初始化mcp服务
mcp = FastMCP("hello-mcp-server")

@mcp.tool(name="url_get", description="访问某个url")
async def get_url(url: str = Field(..., description="将要访问的url")):
    async with httpx.AsyncClient(headers=headers) as client:
        response = await client.get(url)
        return response.text

@mcp.tool(name="计算加法", description="计算加法")
async def add(a: int = Field(..., description="第一个数"), b: int = Field(..., description="第二个数")):
    return a + b

def run():
    mcp.run(transport="sse")

if __name__ == "__main__":
    run()

🔄 使用 asyncio.to_thread() 包装同步阻塞操作

适用于没有异步版本的库(如某些老旧的数据库或第三方 SDK):

from mcp.server.fastmcp import FastMCP
from pydantic import Field
import time
import asyncio
import requests

# 初始化mcp服务
mcp = FastMCP("hello-mcp-server")

@mcp.tool(name="url_get", description="访问某个url")
async def get_url(url: str = Field(..., description="将要访问的url")):
    response = await asyncio.to_thread(requests.get, url)
    return response.text

@mcp.tool(name="计算加法", description="计算加法")
async def add(a: int = Field(..., description="第一个数"), b: int = Field(..., description="第二个数")):
    return a + b

def run():
    mcp.run(transport="sse")

if __name__ == "__main__":
    run()

stdio类型如何选择?

对于stdio类型的mcp服务,由于只有你个客户端连接,调用工具时是顺序调用,所以也就无所谓阻塞与非阻塞,但是出于未来扩展为sse类型mcp,还是建议写成异步async形式的函数。

🧾 总结

✅ 请务必牢记以下几点:

  1. 使用 Python 开发 SSE 类型的 MCP Server 时,一定要警惕 IO 阻塞问题

  2. MCP SDK 底层基于 uvicorn,运行阻塞代码会**影响整个事件循环**

  3. 工具函数必须使用 async def,并确保所有阻塞操作为异步方式

  4. 异步方式的两种选择:

    • 使用异步库(如 httpx, aiomysql, aiofiles 等)

    • 使用 asyncio.to_thread() 将同步操作放入线程池

🧠 记住:在异步世界里,一次阻塞,满盘皆输。