使用 Python 入门 Model Context Protocol(MCP)——高级服务器

70 阅读13分钟

在第 3 章中,你已经了解了如何构建 MCP 服务器。不过,还有另一种更高级的构建方式——其核心目的是让你获得更强的可控性

在本章中,你将学到如何:

  • 使用 上下文管理器(context managers) 管理服务器生命周期
  • 改进服务器的架构设计
  • 理解对 MCP 服务器的**低层级(low-level)**访问方式

本章涵盖以下主题:

  • 为什么采用低层级方法?
  • 上下文管理器
  • MCP 服务器中的上下文管理器
  • 低层级访问
  • 架构组织

为什么采用低层级方法?

此时你可能会想:为什么要这么做? 之前的方法那么简单!——之所以需要这种方法,有以下原因:

  • 用上下文管理器管理服务器生命周期:例如在服务器启动/关闭时连接或断开数据库及其他服务。通过更可控的生命周期管理,你能确保服务器在需要时正确初始化,不再需要时能被妥善清理。
  • 改进服务器架构:更高的掌控力意味着你能更自由地注册工具与资源,也能更灵活地处理入站请求。更强的控制有助于以更可维护、可扩展的方式组织代码。本章会展示如何在“低层级服务器”和“常规 MCP 服务器”两种方式下组织代码——两者都能实现清晰架构,但也有人认为低层级方式更“干净”,因为无需在各处传递服务器实例。稍后你会看到这句话的具体含义。
  • 在某些场景下是唯一可行的方式:比如涉及第 9 章的**采样(sampling)与第 10 章的引导/诱导(elicitation)**等功能时,可能只能采用低层级方法。

接下来我们深入低层级方法,看看它长什么样,以便你为自己的项目选择最合适的路径。

上下文管理器(Context managers)

那么,什么是上下文管理器?上下文管理器是一种结构,让你能在恰当的时机分配与释放资源。最常见的用法是 Python 的 with 语句:即使发生错误,也能确保资源在使用后被正确清理。使用上下文管理器能让代码更整洁、更易读。示例:

with Database_connection() as conn:
    # 使用连接
    result = conn.execute("SELECT * FROM table")
    for row in result:
        print(row)

使用 contextlib 创建上下文管理器

另一种方式是用 contextlib(在 NPM 里也有同名库 contextlib)来编写自定义上下文管理器,无需定义类。示例:

import contextlib

@contextlib.contextmanager
def database_connection():
    conn = connect_to_database()
    try:
        yield conn  # 这里把资源提供给 with 块
    finally:
        close_connection(conn)  # 在此进行清理

可以看到,我们用 database_connection() 函数替代了 DatabaseConnection 类,并通过 @contextlib.contextmanager 装饰器与 yield 提供资源;yield 之后的代码会在块退出时执行,完成清理。这样是否优于上一种写法?至少敲的代码更少

自行实现一个上下文管理器

如果你出于好奇或不想引入额外依赖,也可以实现协议来编写上下文管理器:

class DatabaseConnection:
    def __enter__(self):
        self.conn = self.connect_to_database()
        return self.conn

    def __exit__(self, exc_type, exc_value, traceback):
        self.close_connection(self.conn)

    def connect_to_database(self):
        # 连接数据库的逻辑
        pass

    def close_connection(self, conn):
        # 关闭连接的逻辑
        pass

上述 DatabaseConnection 通过定义 __enter____exit__ 方法实现了上下文管理协议:进入 with 块时调用 __enter__ 返回资源(这里是数据库连接);退出块时调用 __exit__ 完成清理(例如关闭连接)。调用方式如下:

with DatabaseConnection() as conn:
    # 使用连接
    result = conn.execute("SELECT * FROM table")
    for row in result:
        print(row)

如果用上下文管理器,代码可能是这样:

conn = DatabaseConnection().connect_to_database()
try:
    # 使用连接
    result = conn.execute("SELECT * FROM table")
    for row in result:
        print(row)
finally:
    DatabaseConnection().close_connection(conn)

想象一下,如果你忘了finally 中关闭连接会怎样?也许你足够自律,但在大型代码库里很容易疏漏。上下文管理器通过确保始终清理资源来避免这类陷阱。

接下来看看在 MCP 服务器语境中如何使用上下文管理器。

MCP 服务器中的上下文管理器(Context managers in MCP servers)

MCP 允许你控制资源的生命周期管理。来看一段代码:

async def load_settings() -> dict:
    """Load settings from a configuration file."""
    # Simulate loading settings
    return {"setting1": "value1", "setting2": "value2"}

@asynccontextmanager
async def server_lifespan(server: Server) -> AsyncIterator[dict]:
    """Manage server startup and shutdown lifecycle."""
    # Initialize resources on startup
    db = await Database.connect()
    settings = await load_settings()
    try:
        yield {"db": db, "settings": settings}
    finally:
        # Clean up on shutdown
        await db.disconnect()

上面我们做了几件事:

  • 定义了一个异步上下文管理器 server_lifespan,用于管理服务器的生命周期;
  • 在服务器启动时初始化资源(如数据库连接与配置),在服务器关闭时进行清理;
  • 将这些资源暴露给服务器的请求上下文,便于各处理器(handler)获取。

说到处理器,下面展示如何在服务器的处理器中访问这些资源:

# Pass lifespan to server
server = Server("example-server", lifespan=server_lifespan)

# Access lifespan context in handlers
@server.call_tool()
async def query_db(name: str, arguments: dict) -> list:
    ctx = server.request_context
    db = ctx.lifespan_context["db"]
    settings = ctx.lifespan_context["settings"]
    # TODO: Use the database connection and settings
    return await db.query(arguments["query"])

这段代码中我们:

  • 定义了一个工具 query_db,它会访问在 server_lifespan 上下文管理器中初始化的资源;
  • 通过服务器请求上下文的 lifespan_context 取得数据库连接与配置(db = ctx.lifespan_context["db"]settings = ctx.lifespan_context["settings"])。

很好——现在我们理解了上下文管理及其存在的意义。接下来在下一节继续了解低层级访问

低层级访问(Low-level access)

先回顾一下我们最初是如何构建服务器的,以便对比低层级方式的不同。下面是使用高层 API构建一个简单 MCP 服务器的方式:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Echo")

@mcp.resource("echo://{message}")
def echo_resource(message: str) -> str:
    """Echo a message as a resource"""
    return f"Resource echo: {message}"

@mcp.tool()
def echo_tool(message: str) -> str:
    """Echo a message as a tool"""
    return f"Tool echo: {message}"

这里通过 FastMCP 类实例化服务器(本例为 mcp),随后用 @mcp 装饰器定义资源、工具与提示。这是高层的构建方式。

如果你希望对服务器的构建拥有更多控制权,可以使用低层级访问。注册功能的方式会有明显不同:过去你可能习惯为每个工具或资源使用对应的装饰器;而在低层级服务器里,你需要自行处理所有请求——不再“一次只处理一个工具/资源”,而是在一个位置统一处理与工具、资源、提示相关的所有请求

首先,引入方式不同:注意我们从 mcp.server.lowlevel 导入 Server

from mcp.server.lowlevel import Server

然后这样实例化服务器:

server = Server("low-level-server")

接着,不是用 @mcp.tool() / @mcp.resource(),而是用如 @server.list_tools()@server.call_tool()处理器(handler)来注册。这是一个巨大的区别:在低层级服务器中,并非为每个功能单独写装饰器,而是分别实现“列出工具”“调用工具”“处理资源”“处理提示”等入口处理器。例如, @server.list_tools() 需要你自己实现“列出所有工具”的逻辑(高层服务器会替你做这件事)。示例:

@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    tool_list = []
    print(tools)
    for tool in tools.tools.values():
        tool_list.append(
            types.Tool(
                name=tool["name"],
                description=tool["description"],
                inputSchema=tool["input_schema"],
            )
        )
    return tool_list

上述代码中我们:

  • 定义了处理器 handle_list_tools 来响应 list_tools 请求;
  • @server.list_tools() 将该处理器注册到服务器;
  • 遍历 tools.tools.values() 收集所有工具并按要求返回 types.Tool 列表。此处 tools.tools 是一个保存本服务器已注册工具的字典,形如:
{
  "tools": {
    "echo_tool": {
      "name": "echo_tool",
      "description": "Echo a message as a tool",
      "input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}
    }
  }
}

这不是更麻烦了吗?——其实未必。下一节我们会看看为什么这种方式反而可能是组织你代码的好方法。

组织你的架构(Organizing your architecture)

使用低层级服务器的一大优势,是你可以完全掌控服务器的架构;你能以最契合项目的方式组织代码。比如,你可以把所有工具(tools)都放在一个名为 tools 的文件夹中。而且各个工具并不需要知道服务器实例的存在。听起来很不错,对吧?下面看看如何实现。

当然,使用高层级服务器也能把代码组织得不错,但通常会更“乱”一些,因为正如本章一开始所说,你需要在各处传递服务器实例

下面是你至今在高层级服务器里定义 MCP 服务器特性的方式:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Echo")

@mcp.tool()
def echo_tool(message: str) -> str:
    """Echo a message as a tool"""
    return f"Tool echo: {message}"

@mcp.tool()
def add_tool(a: int, b: int) -> int:
    """Add two numbers as a tool"""
    return a + b

@mcp.tool()
def subtract_tool(a: int, b: int) -> int:
    """Subtract two numbers as a tool"""
    return a - b

@mcp.tool()
def multiply_tool(a: int, b: int) -> int:
    """Multiply two numbers as a tool"""
    return a * b

@mcp.tool()
def divide_tool(a: int, b: int) -> float:
    """Divide two numbers as a tool"""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

以上这种定义服务器及其特性的方式并无不妥。不过,你也许会倾向于让入口文件尽量“干净”(只保留服务器定义),把所有特性都放到单独文件里。于是项目结构可能会变成:

project/
├── server.py
├── tools.py

此时 server.py 可能如下:

# server.py
from mcp.server.fastmcp import FastMCP
import tools

mcp = FastMCP("Echo")
tools.register_tools(mcp)
# code for running the server

tools.py 可能是这样:

from mcp.server.fastmcp import FastMCP

def register_tools(mcp: FastMCP):
    @mcp.tool()
    def echo_tool(message: str) -> str:
        """Echo a message as a tool"""
        return f"Tool echo: {message}"

    @mcp.tool()
    def add_tool(a: int, b: int) -> int:
        """Add two numbers as a tool"""
        return a + b

    @mcp.tool()
    def subtract_tool(a: int, b: int) -> int:
        """Subtract two numbers as a tool"""
        return a - b

    @mcp.tool()
    def multiply_tool(a: int, b: int) -> int:
        """Multiply two numbers as a tool"""
        return a * b

    @mcp.tool()
    def divide_tool(a: int, b: int) -> float:
        """Divide two numbers as a tool"""
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

你甚至可以为每个工具准备一个独立文件:

project/
├── server.py
├── tools/
│   ├── echo.py
│   ├── add.py
│   ├── subtract.py
│   ├── multiply.py
│   └── divide.py

所以说,你完全可以按项目需求去组织代码。但想摆脱传递服务器实例这件事会很难。需要说明的是,高层级服务器确实提供了一个 create_tool() 函数,允许你在不使用 @mcp.tool() 装饰器的情况下创建工具。不过(作者观点),在这种需求下,用低层级服务器会更容易。我们在下一节看看如何做到。

在低层级服务器中构造 “list tools” 响应

看看低层级访问是否能进一步改善我们的架构。目标是:让服务器以易维护、易扩展的方式注册工具、资源与提示。

到目前为止,我们对低层级服务器的结论是:

  • 不再使用 FastMCP,而是使用 mcp.server.lowlevel 中的 Server 类;
  • 通过诸如 @server.list_tools()@server.call_tool() 这样的**处理器(handler)**来注册特性,分别用于“列出所有工具”和“处理所有工具调用”。

下面更详细地看看这些处理器应如何构造,以便加以利用:

@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    tool_list = []
    tool_list.append(
        types.Tool(
            name=tool["name"],
            description=tool["description"],
            inputSchema=tool["input_schema"],
        )
    )
    return tool_list

这里注意返回类型是 list[types.Tool] 。这体现在代码里:我们把一个 types.Tool 对象 append 到 tool_list

tool_list.append(
    types.Tool(
        name=tool["name"],
        description=tool["description"],
        inputSchema=tool["input_schema"],
    )
)

组织代码并创建工具与模式(schemas)

既然知道如何注册工具了,接下来看如何更好地组织代码。目标:

  • 每个工具一个文件,便于管理;
  • 在一个地方统一注册所有工具。

听起来很棒吧?谁不想要更好的可维护性呢!让我们创建如下目录结构:

server.py
tools/
├── __init__.py
├── add.py

也就是说,用 server.py 作为服务器的入口文件,用 tools/ 文件夹存放所有工具;tools/__init__.py 用来收集并注册所有工具;随后由 server.py@mcp.list_tools()(按上下文应为低层级的 @server.list_tools())来统一注册。先看 __init__.py

from .add import tool_add

tools = {
    tool_add["name"] : tool_add
}

这看起来非常简单:仅是一个字典,从 add.py 导入 tool_add,并把它加入 tools 字典。

再看 add.py

# add.py
from .schema import AddInputModel

async def add_handler(args) -> float:
    try:
        # Validate input using Pydantic model
        input_model = AddInputModel(**args)
    except Exception as e:
        raise ValueError(f"Invalid input: {str(e)}")

    # TODO: add Pydantic, so we can create an AddInputModel and validate args
    """Handler function for the add tool."""
    return float(input_model.a) + float(input_model.b)

tool_add = {
    "name": "add",
    "description": "Adds two numbers",
    "input_schema": AddInputModel,
    "handler": add_handler
}

以上我们做了这些事:

  • 引入 AddInputModel,并把它作为 input_schema
  • 定义 add_handler,接收参数并返回它们的和;
  • 创建 tool_add 字典,包含工具名称、描述、输入模式与处理函数。

如你所见,这里没有任何 MCP 相关的 import,因此代码显得很“干净”。

最后看看 schema.py

from pydantic import BaseModel

class AddInputModel(BaseModel):
    a: float
    b: float

这里使用 Pydantic 定义输入模型;随着增加更多工具,你可以在此扩展更多类型。

处理“工具调用”请求

目前为止,你已经看到了我们如何处理“列出所有工具”。还需要处理另一种情况:客户端调用某个工具。为此,对入站的工具调用请求要做两件事:

  1. 识别要调用的是哪个工具;
  2. 解析与校验参数(这里就要用到 Pydantic 的 schema)。

从服务器端的请求处理开始:

# server.py
@server.call_tool()
async def handle_call_tool(
    name: str, arguments: dict[str, str] | None
) -> list[types.TextContent]:
    pass

注意这里使用的是 @server.call_tool 装饰器,且返回类型需要是 list[types.TextContent]

接下来,识别正确的工具:

# server.py
if name not in tools.tools:
    raise ValueError(f"Unknown tool: {name}")

tool = tools.tools[name]

准备好调用该工具的处理函数,并向调用方构造响应:

# server.py
try:
    result = await tool["handler"](arguments)
except Exception as e:
    raise ValueError(f"Error calling tool {name}: {str(e)}")

return [
    types.TextContent(type="text", text=str(result))
]

注意我们通过 tool["handler"](arguments) 调用了工具对象上的 handler 属性。那么处理器(handler)长什么样、如何工作?回到 add.py

# add.py
from .schema import AddInputModel

async def add_handler(args) -> float:
    try:
        # Validate input using Pydantic model
        input_model = AddInputModel(**args)
    except Exception as e:
        raise ValueError(f"Invalid input: {str(e)}")

    print (f"Adding {args['a']} and {args['b']}")
    """Handler function for the add tool."""
    return float(args['a']) + float(args['b'])

这里我们用 try-exceptargs 传给 AddInputModel;它接收一个字典,通过 **args 将字典解包成关键字参数。若输入无效,就抛出 ValueError,并附上出错原因。这样便可确保传给工具的输入始终有效符合预期模式

至此,我们已经同时完成了“列出所有工具”与“调用具体工具”,并在过程中对入参进行了基本校验。

你当然还能继续改进这套方案,但相较一开始的做法,这已经好得多了。

总结

本章你学会了如何使用低层级服务器为 MCP 服务器构建一个更易维护的架构:你看到了如何注册工具、处理请求,并使用模式(schema)对输入进行校验。该方法让你可以更容易地新增工具、管理已有工具,从而使服务器更灵活、更易维护。

下一章我们将讲解如何构建客户端,以便与我们的 MCP 服务器进行交互。