在第 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 定义输入模型;随着增加更多工具,你可以在此扩展更多类型。
处理“工具调用”请求
目前为止,你已经看到了我们如何处理“列出所有工具”。还需要处理另一种情况:客户端调用某个工具。为此,对入站的工具调用请求要做两件事:
- 识别要调用的是哪个工具;
- 解析与校验参数(这里就要用到 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-except 把 args 传给 AddInputModel;它接收一个字典,通过 **args 将字典解包成关键字参数。若输入无效,就抛出 ValueError,并附上出错原因。这样便可确保传给工具的输入始终有效且符合预期模式。
至此,我们已经同时完成了“列出所有工具”与“调用具体工具”,并在过程中对入参进行了基本校验。
你当然还能继续改进这套方案,但相较一开始的做法,这已经好得多了。
总结
本章你学会了如何使用低层级服务器为 MCP 服务器构建一个更易维护的架构:你看到了如何注册工具、处理请求,并使用模式(schema)对输入进行校验。该方法让你可以更容易地新增工具、管理已有工具,从而使服务器更灵活、更易维护。
下一章我们将讲解如何构建客户端,以便与我们的 MCP 服务器进行交互。