mcp-for-beginners 进阶指南:用 MCP 低层服务器(Low-Level Server)打造可扩展、可验证的工具架构

原创2026-10-03 13:36:431,670 阅读
文章标签:文档教程人工智能

mcp-for-beginners 进阶指南:用 MCP 低层服务器(Low-Level Server)打造可扩展、可验证的工具架构

本篇文章是 mcp-for-beginners 开源课程《Getting Started》系列第 10 章的技术实战指南,核心主题是 MCP SDK 中两种服务器形态的取舍:常规服务器(Regular Server)与低层服务器(Low-Level Server)。文中将通过 Python 与 TypeScript 双语言对照,演示如何用"每种能力仅两个请求处理器"的低层模式,把工具(tools)、资源(resources)、提示词(prompts)按目录组织成清晰可扩展的架构,并借助 Pydantic 与 Zod 完成输入校验。读完本文,你将掌握低层服务器的 tools/list 与 tools/call 请求处理机制,并能够基于仓库提供的完整示例搭建自己的低层 MCP 服务器。

为什么需要低层服务器

MCP SDK 向开发者暴露了两种服务器类型:常规服务器(Regular Server)与低层服务器(Low-Level Server)。日常开发中,常规服务器已经足够好用——你只需通过装饰器或注册方法逐条声明工具、资源与提示词即可。但存在两类场景,你会更希望依赖低层服务器:

  • 更好的架构:虽然用常规服务器同样可以写出整洁的分层代码,但低层服务器在架构组织上要更自然、更省事。
  • 高级特性可用性:部分高级特性(如后续章节将要讲解的 Elicitation 与旧版 Sampling 特性,后者在 MCP 2026-07-28 规范中已被标记为废弃)只有在低层服务器上才能使用。

理解两种模式的差异,是正确选择实现路径的前提。

常规服务器:逐条注册能力

先看常规服务器如何创建一个 MCP 服务器。在 03-GettingStarted/10-advanced/README.md 中给出了两种语言的对照写法。

Python(基于 FastMCP)

mcp = FastMCP("Demo")

# 添加一个加法工具
@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b

TypeScript(基于 McpServer)

const server = new McpServer({
  name: "demo-server",
  version: "1.0.0"
});

// 添加一个加法工具
server.registerTool("add",
  {
    title: "Addition Tool",
    description: "Add two numbers",
    inputSchema: { a: z.number(), b: z.number() }
  },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }]
  })
);

两种写法的共同点非常明确:你显式地逐条添加服务器希望拥有的每个工具、资源或提示词。这种模式直观、适合原型与小规模服务,但当能力数量增长后,所有注册代码会堆积在入口文件里,架构边界逐渐模糊。

低层服务器:每个能力类型只写两个处理器

低层服务器的思维方式完全不同——不再逐条注册工具,而是为每种能力类型(工具、资源、提示词)各准备两个处理器。以工具为例:

  • 列出所有工具:一个函数负责响应所有 tools/list 请求。
  • 处理所有工具调用:同样只有一个函数处理任何 tools/call 请求。

换言之,你不再"注册"某个工具,而是确保两件事:当列出工具时它出现在清单里;当有调用请求进来时它能够被正确调用。这种思路立刻让工作量看起来更少、更集中。

第一个处理器:列出工具

Python

@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    """List available tools."""
    return [
        types.Tool(
            name="add",
            description="Add two numbers",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "number", "description": "number to add"},
                    "b": {"type": "number", "description": "number to add"}
                },
                "required": ["query"],
            },
        )
    ]

TypeScript

server.setRequestHandler(ListToolsRequestSchema, async (request) => {
  // 返回已注册的工具列表
  return {
    tools: [{
        name: "add",
        description: "Add two numbers",
        inputSchema: {
            "type": "object",
            "properties": {
                "a": {"type": "number", "description": "number to add"},
                "b": {"type": "number", "description": "number to add"}
            },
            "required": ["query"],
        }
    }]
  };
});

注意两个细节:

  1. 返回的工具列表中的每一项都包含 name、description、inputSchema 字段,这与 MCP 协议中 Tool 类型的返回结构完全一致;
  2. 示例中的 "required": <a href="https://link.gitcode.com/i/fe2cbddf62fa916bb07c3ca149f63f66" target="_blank">"query"] 是原文文档遗留的笔误——对照仓库完整实现(如 [python/server.py 与 TypeScript 端由 Zod 生成的 JSON Schema),真正的必填参数应为 ["a", "b"]。

工具可以放到任意位置

由于列工具只是"遍历一份清单",工具的物理存放位置就不再受限于服务器入口文件。我们可以把每个工具、资源和提示词都放到自己的目录中,项目结构随之变得非常规整:

app
--| tools
----| add
----| substract
--| resources
----| products
----| schemas
--| prompts
----| product-description

第二个处理器:调用工具

调用任意工具同样只有一个处理器。处理器负责从请求中解析出"要调用哪个工具、带哪些参数",再去真正执行调用。

Python

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

    # tools 是以工具名为键的字典
    if name not in tools.tools:
        raise ValueError(f"Unknown tool: {name}")

    tool = tools.tools[name]

    result = "default"
    try:
        result = await tool"handler"
    except Exception as e:
        raise ValueError(f"Error calling tool {name}: {str(e)}")

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

说明:原文文档此处将 tool"handler" 误渲染为一段残缺的相对链接,仓库中的真实实现即为 python/server.py 所示,参数原样传入处理器函数。

TypeScript

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { params: { name } } = request;
    let tool = tools.find(t => t.name === name);
    if(!tool) {
        return {
            error: {
                code: "tool_not_found",
                message: `Tool ${name} not found.`
            }
       };
    }

    // args: request.params.arguments
    // TODO call the tool,

    return {
       content: [{ type: "text", text: `Tool ${name} called with arguments: ${JSON.stringify(input)}, result: ${JSON.stringify(result)}` }]
    };
});

到这里,我们已经看到低层服务器的核心机制:注册工作被"清单维护 + 统一分发"两个处理器取代。

用校验(Validation)改进调用流程

仅有分发还不够——工具必须用正确的参数被调用。因此我们要做两件事:

  • 把创建特性(工具、资源、提示词)的逻辑移动到各自的专属目录;
  • 为收到的调用请求增加参数校验。

每种运行时都有自己的校验方案:Python 用 Pydantic,TypeScript 用 Zod。校验逻辑既可以放在统一的调用处理器里,也可以下沉到具体工具的 handler 函数中,两种做法各有利弊,下文分别展示。

Python:Pydantic 定义 Schema 并校验

在 schema.py 中用 Pydantic 定义输入模型:

# schema.py
from pydantic import BaseModel

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

在 add.py 中尝试把请求参数解析为 AddInputModel,参数不匹配时抛错:

# add.py
from .schema import AddInputModel

async def add_handler(args) -> float:
    try:
        # 使用 Pydantic 模型校验输入
        input_model = AddInputModel(**args)
    except Exception as e:
        raise ValueError(f"Invalid input: {str(e)}")

    # 处理器函数:执行实际加法
    return float(input_model.a) + float(input_model.b)

然后以字典形式对外暴露工具的全部元信息:

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

你可以自主决定把这段解析校验逻辑放在调用处理器里,还是放在 handler 函数里。仓库示例选择的是后者——校验在 add_handler 内完成,统一处理器则负责分发与兜底(见 python/tools/add.py)。

TypeScript:Zod 定义 Schema 并在处理器中校验

server.ts 中的调用处理器引入 rawSchema 解析参数,成功后再调用实际回调:

// server.ts
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { params: { name } } = request;
    let tool = tools.find(t => t.name === name);
    if (!tool) {
       return {
        error: {
            code: "tool_not_found",
            message: `Tool ${name} not found.`
        }
       };
    }
    const Schema = tool.rawSchema;

    try {
       const input = Schema.parse(request.params.arguments);

       // @ts-ignore
       const result = await tool.callback(input);

       return {
          content: [{ type: "text", text: `Tool ${name} called with arguments: ${JSON.stringify(input)}, result: ${JSON.stringify(result)}` }]
      };
    } catch (error) {
       return {
          error: {
             code: "invalid_arguments",
             message: `Invalid arguments for tool ${name}: ${error instanceof Error ? error.message : String(error)}`
          }
    };
   }

});

schema.ts 集中存放各工具的 Zod 输入模式:

// schema.ts
import { z } from 'zod';

export const MathInputSchema = z.object({ a: z.number(), b: z.number() });

add.ts 组装工具定义,借助 zod-to-json-schema 同时产出协议层可用的 JSON Schema:

// add.ts
import { Tool } from "./tool.js";
import { MathInputSchema } from "./schema.js";
import { zodToJsonSchema } from "zod-to-json-schema";

export default {
    name: "add",
    rawSchema: MathInputSchema,
    inputSchema: zodToJsonSchema(MathInputSchema),
    callback: async ({ a, b }) => {
        return {
            content: [{ type: "text", text: String(a + b) }]
        };
    }
} as Tool;
  • 处理器先用 Schema.parse(request.params.arguments) 校验入参;
  • 校验通过后调用 await tool.callback(input) 执行真实逻辑;
  • 校验失败则返回 invalid_arguments 错误码。

这样的架构让一切各归其位:server.ts 只做请求处理器的装配(非常短小),每个特性则位于 tools/、resources/ 或 prompts/ 各自的目录中。

练习:动手构建一个低层服务器

接下来我们基于仓库 03-GettingStarted/10-advanced/code 中的完整示例,逐步构建一个可运行的、带校验的低层服务器。练习目标:

  1. 创建一个低层服务器,处理工具列表与工具调用;
  2. 实现一个可继续扩展的架构;
  3. 添加校验,确保工具调用参数被正确验证。

-1- 搭建项目架构

Python 目录结构:

server.py
--| tools
----| __init__.py
----| add.py
----| schema.py
client.py

TypeScript 目录结构:

server.ts
--| tools
----| add.ts
----| schema.ts
client.ts

这套架构保证了"新增工具只需往 tools 目录加文件"。你也可以照此思路为资源和提示词建立子目录。

-2- 创建工具

Python:工具文件 add.py 定义名称、描述、Pydantic 输入模式与处理器,并导出字典 tool_add;schema.py 定义输入模式;init.py 让 tools 目录成为模块并集中导出工具字典,之后每加一个新工具只需在 __init__.py 追加一条:

# tools/__init__.py
from .add import tool_add

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

仓库实现与此一致(见 python/tools/init.py、python/tools/add.py、python/tools/schema.py)。

TypeScript:每个工具默认导出一个包含四类属性的对象,并通过 Tool 接口将其转换为 MCP 处理器可接受的类型(见 typescript/src/tools/tool.ts):

import { z } from 'zod';

export interface Tool {
    name: string;
    inputSchema: any;
    rawSchema: z.ZodTypeAny;
    callback: (args: z.infer<z.ZodTypeAny>) => Promise<{ content: { type: string; text: string }[] }>;
}

各属性的分工:

  • name:工具名称;
  • rawSchema:Zod 模式,用于校验调用该工具的入参;
  • inputSchema:由 zodToJsonSchema 生成的 JSON Schema,供处理器 / 协议层使用;
  • callback:真正调用工具时执行的函数。

schema.ts 集中存放各工具的输入模式,随着工具增多持续追加条目(见 typescript/src/tools/schema.ts)。

-3- 处理工具列表

Python:使用 @server.list_tools() 装饰器实现 handle_list_tools,遍历工具字典并转换为 types.Tool。注意每个工具都需要 name、description 与 inputSchema。仓库完整实现还包含一个 convert_to_json 辅助函数,负责把 Pydantic 模型的 schema() 转换为协议的 JSON Schema 结构(见 python/server.py)。

TypeScript:在服务器上通过 setRequestHandler(ListToolsRequestSchema, ...) 注册列表处理器。工具清单由 tools/index.ts 集中维护:它导入 addTool、subtractTool 并 push 进 tools 数组(见 typescript/src/tools/index.ts),server.ts 只需返回该数组(见 typescript/src/server.ts)。

-4- 处理工具调用

Python:用 @server.call_tool() 装饰器实现 handle_call_tool。函数内先校验工具名是否存在于工具字典,找不到即抛出 Unknown tool;随后以参数调用 handler,异常被包装为 Error calling tool 抛出让客户端感知(见 python/server.py)。

TypeScript:注册 CallToolRequestSchema 处理器。流程是:按 name 在工具数组中查找 → 不存在则返回 tool_not_found 错误 → 用 rawSchema 解析参数 → 调用 callback → 成功返回文本内容,失败返回 invalid_arguments 错误(见 typescript/src/server.ts)。

至此,你已经完整掌握了低层服务器的工具列表与工具调用机制。

运行与验证完整示例

Python 端

按照 code/python/README.md 的步骤:

python -m venv venv
source ./venv/bin/activate
pip install "mcp[cli]"
python client.py

预期输出:

Available tools: ['add']
Result of add tool: meta=None content=[TextContent(type='text', text='8.0', annotations=None, meta=None)] structuredContent=None isError=False

客户端 client.py 通过 stdio 启动 server.py,建立 ClientSession 后依次调用 session.list_tools() 与 session.call_tool("add", { "a": 5, "b": 3 }),直接验证了低层服务器的列表与调用两个处理器。

TypeScript 端

按照 code/typescript/README.md 的步骤:

npm install
npm run build
npm start

预期看到:

Registering tools...
Starting server...

随后可用 MCP Inspector 进行交互式或命令行测试:

npx @modelcontextprotocol/inspector node build/app.js

CLI 模式列出工具:

npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/list

返回结果中 add 与 subtract 两个工具的 inputSchema 均由 Zod 自动转换为 JSON Schema(含 required: ["a","b"] 与 additionalProperties: false),这正是 zod-to-json-schema 与 rawSchema 协作的产物。

CLI 模式调用工具:

npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add --tool-arg a=1 --tool-arg b=2

预期看到结果文本中回显参数与计算结果。

调用不存在的工具:

npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add2 --tool-arg a=1 --tool-arg b=2

预期返回:

{
  "content": [],
  "error": {
    "code": "tool_not_found",
    "message": "Tool add2 not found."
  }
}

传入非法参数(缺少 b、多出 c):

npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add --tool-arg a=1 --tool-arg c=2

预期返回 invalid_arguments 错误,并附带 Zod 给出的详细失败原因(例如 b 字段缺失),这直观证明了校验逻辑已生效。

作业:扩展你的服务器

尝试在现有代码基础上新增若干工具、资源与提示词,并观察一个事实:你只需要在 tools(以及对应的 resources / prompts)目录中新增文件,无需改动服务器入口文件——因为列表与调用都由统一处理器驱动。例如 Python 端只需在 tools/__init__.py 追加 from .subtract import tool_subtract,TypeScript 端只需在 tools/index.ts 中 import subtractTool 并 tools.push(subtractTool)。

本练习不提供标准答案,因为不同的扩展方向各有合理的实现。

总结

本章围绕低层服务器展开:我们看到了它与常规服务器的本质差异——以"每类能力两个处理器"替代逐条注册;掌握了用 Pydantic 与 Zod 构建输入校验模式、保证工具以正确参数被调用的方法;并通过 Python / TypeScript 双语言完整示例,实践了可扩展目录架构的搭建与 MCP Inspector 的验证流程。

下一步建议进入简单认证(Simple Authentication)章节,为低层服务器接入鉴权能力,进一步靠近生产级 MCP 服务。

登录后查看全文
mcp-for-beginners