mcp-for-beginners 高级服务器用法:用 Low-Level Server 构建可扩展的 MCP 工具架构

原创2026-10-10 18:07:531,753 阅读
文章标签:文档教程人工智能

mcp-for-beginners 高级服务器用法:用 Low-Level Server 构建可扩展的 MCP 工具架构

导读

在 MCP(Model Context Protocol)开发中,常规服务器(Regular Server)与底层服务器(Low-Level Server)代表了两种截然不同的扩展思路。本篇技术指南以 mcp-for-beginners 课程的“高级服务器用法”章节为核心,对比常规服务器"逐个注册工具"与底层服务器"每类功能只写两个 Handler"两种模式,并通过 Python(Pydantic)与 TypeScript(Zod)双语言实现,手把手带你搭建一个支持工具列表、工具调用与入参校验的低层服务器,最终得到一个只需在 tools/ 目录新增文件即可无限扩展的模块化架构。

为什么要关注 Low-Level Server

MCP SDK 暴露了两类服务器:常规服务器(Regular Server)与低层服务器(Low-Level Server)。常规服务器足以覆盖绝大多数功能扩展需求,但以下两类场景会让你更倾向于使用低层服务器:

  • 更好的架构:虽然用常规服务器也能搭建整洁的分层架构,但使用低层服务器时,工具、资源与提示词(Prompt)天然以"目录即模块"的方式组织,架构更易于维护与扩展。
  • 功能可用性:部分高级能力只能通过低层服务器使用。课程中提到,后续章节会涉及 Elicitation 与 legacy Sampling 功能,其中 Sampling 在 MCP 2026-07-28 规范中已被标记为弃用。

关于 2026-07-28 规范的背景,可以参考仓库中的 01-CoreConcepts/mcp-2026-07-28.md:在该版本中,Roots、Sampling、Logging 与动态客户端注册均进入 Deprecated 生命周期,Sampling 的官方替代方案是"直接集成 LLM 提供方 API"。因此,当你需要使用这些只能在低层服务器上启用的高级能力时,理解低层服务器的编程模型是必要前提。

常规服务器与低层服务器的对比

常规服务器:显式注册每个功能

常规服务器的核心思路是:你想让服务器拥有哪些工具、资源或提示词,就逐个显式注册它们。

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) }]
  })
);

这种模式本身没有任何问题,课程前序章节(如 03-GettingStarted/01-first-server/README.md)正是基于 FastMCP 与 @mcp.tool() 装饰器完成第一个服务器构建。但当功能数量增长后,注册逻辑与业务逻辑交织在同一处,架构会逐渐变得难以维护。

低层服务器:每类功能只写两个 Handler

低层服务器要求你换一种思维方式:不再逐个注册工具,而是为每种功能类型(工具、资源或提示词)只创建两个 Handler。以工具为例:

  • 列出所有工具:一个函数统一负责所有"列出工具"的请求;
  • 调用所有工具:一个函数统一处理所有"调用工具"的请求。

这意味着,新增工具时你不再需要触碰服务器注册代码,只需保证"列出时它会被返回,调用时它会被路由到正确的处理函数"即可。

用低层服务器实现工具列表,代码如下:

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"],
        }
    }]
  };
});

注意,工具列表中的每个条目都必须携带 name、description 与 inputSchema 字段以符合返回类型要求。由于工具定义与服务器 Handler 解耦,你可以把工具与功能定义放到独立目录中,项目结构突然变得非常清爽:

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

工具调用同样遵循"一个 Handler 管所有调用"的思路:

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))
    ]

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 调用工具

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

如你所见,调用处理器的职责非常清晰:先从请求中解析出"要调用哪个工具、携带什么参数",再把请求路由到对应工具的执行逻辑。

用校验改进架构:Pydantic 与 Zod

仅仅拥有"列表 + 调用"两个 Handler 还不够,你还需要入参校验,确保工具始终以正确的参数被调用。每种运行时都有对应的校验方案:Python 使用 Pydantic,TypeScript 使用 Zod。改进思路是:

  1. 把创建功能(工具、资源或提示词)的逻辑移到各自的专属目录;
  2. 为入站请求(例如工具调用请求)增加校验能力。

Python:用 Pydantic 定义 Schema 并校验

先创建 schema.py,用 Pydantic 定义输入模型:

# schema.py
from pydantic import BaseModel

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

再创建 add.py,在 Handler 中尝试把入参解析为 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)}")

    """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
}

这段代码完成了三件事:定义名称与描述;把输入模型作为 input_schema;暴露一个持有全部属性的 tool_add 字典。你既可以把解析逻辑放在工具调用处理器中,也可以像这里一样放在 Handler 函数内部,两种位置都可以。

TypeScript:用 Zod 定义 Schema 并校验

在服务器文件中,统一调用处理器现在会先尝试把入参解析为工具定义的 Schema:

// 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 定义放在 schema.ts:

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

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

工具定义放在 add.ts,通过 zodToJsonSchema 把 Zod Schema 转换为 MCP 所需的 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;

解析成功后便调用工具本体:

const result = await tool.callback(input);

校验失败则返回 invalid_arguments 错误。这一套组合拳让架构非常清晰:server.ts 是一份极小的文件,只负责把请求 Handler 接线;每个功能都安放在自己的目录(tools/、resources/、prompts/)中。

实战练习:亲手搭建一个 Low-Level Server

下面进入课程练习,目标是:创建一个负责"列出工具 + 调用工具"的低层服务器,落地一套可持续扩展的架构,并加入入参校验。

第 1 步:创建目录架构

一个支持随功能增长而横向扩展的架构如下:

Python

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

TypeScript

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

这套结构保证:新增工具只需在 tools/ 目录中添加文件;资源与提示词可按同样方式建立 resources/、prompts/ 子目录。

第 2 步:创建工具

Python:在 tools/ 子目录中创建工具文件。定义名称、描述、基于 Pydantic 的输入 Schema 与被调用时执行的 Handler,最后以字典 tool_add 暴露全部属性:

# 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)}")

    """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
}

配套的 schema.py 定义工具输入 Schema:

# schema.py
from pydantic import BaseModel

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

还需要填充 __init__.py,让 tools/ 被当作 Python 模块,并暴露其中的模块:

# __init__.py
from .add import tool_add

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

后续每新增一个工具,只需往这个字典里追加一条记录。

TypeScript:工具是一个包含四类属性的字典对象:

  • name:工具名称;
  • rawSchema:Zod Schema,用于校验调用该工具的入站请求;
  • inputSchema:供 Handler 使用的 JSON Schema;
  • callback:实际调用工具的函数。
// 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;

Tool 接口负责把这个字典转换为 MCP 服务器 Handler 可以接受的类型:

// 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 }[] }>;
}

schema.ts 集中存放各工具的输入 Schema,目前只有一个条目,新增工具时继续追加即可:

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

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

第 3 步:处理工具列表

在服务器文件中注册"列出工具"的请求处理器。

Python:添加 @server.list_tools 装饰器与 handle_list_tools 实现函数,遍历 tools 字典生成工具列表。每个工具都必须包含名称、描述与 inputSchema:

# server.py(为简洁省略其他代码)
from tools import tools

@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    tool_list = []

    for tool in tools.values():
        tool_list.append(
            types.Tool(
                name=tool["name"],
                description=tool["description"],
                inputSchema=pydantic_to_json(tool["input_schema"]),
            )
        )
    return tool_list

TypeScript:在服务器上调用 setRequestHandler,传入 ListToolsRequestSchema:

// index.ts
import addTool from "./add.js";
import subtractTool from "./subtract.js";
import {server} from "../server.js";
import { Tool } from "./tool.js";

export let tools: Array<Tool> = [];
tools.push(addTool);
tools.push(subtractTool);
// server.ts(为简洁省略其他代码)
import { tools } from './tools/index.js';

server.setRequestHandler(ListToolsRequestSchema, async (request) => {
  // 返回已注册工具列表
  return {
    tools: tools
  };
});

第 4 步:处理工具调用

Python:使用 @server.call_tool 装饰器实现 handle_call_tool。函数内部分离出工具名与其参数,并确保参数对该工具有效。参数校验可以放在本函数内,也可以下放到实际工具中:

@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))
    ]

运行机制拆解:

  • 工具名已作为输入参数 name 传入,参数则存放在 arguments 字典中;
  • 通过 result = await tool"handler" 调用工具,参数校验发生在 handler 属性指向的函数内部;若校验失败会抛出异常。

到这里,你已经完整掌握了使用低层服务器"列出工具"与"调用工具"的全部套路。

仓库源码级验证:完整示例对照

课程附带了可直接运行的完整示例,与上文讲解一一对应:

Python 示例(03-GettingStarted/10-advanced/code/python/):

  • server.py 中,服务器通过 Server("example-server") 创建,handle_list_tools 遍历 tools 字典并借助 convert_to_json() 把 Pydantic 模型转换为 JSON Schema;handle_call_tool 通过 tool"handler" 路由调用,未知名工具直接抛出 ValueError。
  • tools/add.py 与 tools/schema.py 实现了"Schema 定义 + Handler + 工具字典"的完整工具模块。
  • tools/init.py 把 add 工具注册进 tools 字典,新增工具只需继续追加。
  • client.py 通过 stdio_client + ClientSession 启动会话,调用 session.list_tools() 与 session.call_tool("add", {"a": 5, "b": 3}) 验证端到端链路。

TypeScript 示例(03-GettingStarted/10-advanced/code/typescript/):

  • src/server.ts 使用 ListToolsRequestSchema 与 CallToolRequestSchema 两个请求 Schema 注册 Handler;调用处理器先经 tool.rawSchema.parse() 校验参数,再执行 tool.callback(input),并区分 tool_not_found 与 invalid_arguments 两类错误码。
  • src/tools/add.ts 与 src/tools/subtract.ts 展示了"同一 Schema、不同 callback"的工具复用模式。
  • src/tools/index.ts 通过 tools.push(addTool) 集中登记工具,文件中被注释掉的 server.tool(...) 调用恰好展示了常规服务器注册写法与低层写法的差异。
  • 依赖配置见 package.json,其中 @modelcontextprotocol/sdk 版本要求 >=1.26.0。

运行与验证

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

TypeScript:按 code/typescript/README.md 操作:

npm install
npm run build
npm start

启动成功会看到 Registering tools... 与 Starting server...。随后可用 MCP Inspector 的 CLI 模式做三类验证:

# 1. 列出工具
npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/list

# 2. 正常调用工具
npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add --tool-arg a=1 --tool-arg b=2

# 3. 调用不存在的工具(应返回 tool_not_found)
npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add2 --tool-arg a=1 --tool-arg b=2

# 4. 传入 Schema 拒绝的参数(应返回 invalid_arguments)
npx @modelcontextprotocol/inspector --cli node ./build/app.js --method tools/call --tool-name add --tool-arg a=1 --tool-arg c=2

tools/list 会返回包含 add 与 subtract 两个工具的 JSON Schema(含 required、additionalProperties 等字段);调用 add2 时返回 {"code": "tool_not_found", "message": "Tool add2 not found."};传入非法参数 c 时返回 invalid_arguments 并携带 Zod 的详细校验错误信息。这三条命令完整印证了"列表、路由、校验"三个环节全部生效。

作业:按你的需求扩展

课程的作业设计非常能体现低层服务器的价值:在现有代码基础上扩展一批工具、资源与提示词,并反思——你会发现只需要在 tools/ 目录中添加文件,其他地方一概不用改动。

课程未提供标准答案,这正是刻意留给你独立实践的空间。

本章小结

本章围绕低层服务器展开:

  • 理解了低层服务器"每类功能(工具、资源、提示词)仅两个 Handler"的核心编程模型——一个负责列表、一个负责调用;
  • 看到了这种模型如何天然催生"目录即模块"的整洁架构,使服务器主文件保持极小,功能按类型各自归位;
  • 掌握了用校验库(Python Pydantic / TypeScript Zod)创建输入 Schema、对入站调用请求做参数校验的标准做法;
  • 通过完整的 Python 与 TypeScript 示例,走通了从目录设计、工具创建、列表处理到调用处理的全流程。

下一章将在此基础上引入鉴权能力:简单认证(Simple Authentication)。

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