Skip to main content
The MCP SDK exposes two server styles: the high-level server (e.g. FastMCP, McpServer) you used in the previous lessons, and a low-level server that gives you complete control over how tools, resources, and prompts are listed and called.

When to use the low-level server

Better architecture

Register all tools through two handlers instead of individual tool() calls, enabling a clean folder-based project structure.

Advanced feature access

Some features — such as sampling and elicitation — are only available through the low-level server API.

High-level vs. low-level comparison

High-level (FastMCP)

Each tool is registered individually with a decorator:
mcp = FastMCP("Demo")

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

Low-level (Server)

Two handlers replace all individual registrations — one to list all tools, one to call any tool:
from mcp.server import Server
import mcp.types as types

server = Server("demo-server")

@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": "first number"},
                    "b": {"type": "number", "description": "second number"}
                },
                "required": ["a", "b"],
            },
        )
    ]

@server.call_tool()
async def handle_call_tool(
    name: str, arguments: dict | None
) -> list[types.TextContent]:
    if name not in tools:
        raise ValueError(f"Unknown tool: {name}")

    tool = tools[name]
    result = await tool["handler"](arguments)
    return [types.TextContent(type="text", text=str(result))]

Modular project structure

With the low-level approach you can organize your project cleanly:
app/
├── tools/
│   ├── add/
│   │   ├── schema.py   # Pydantic model / Zod schema
│   │   └── handler.py  # Tool logic
│   └── subtract/
│       ├── schema.py
│       └── handler.py
├── resources/
│   ├── products/
│   └── schemas/
├── prompts/
│   └── product-description/
└── server.py           # Registers the two list/call handlers

Adding validation

Use your runtime’s schema library to validate tool arguments before calling handler logic.
# tools/add/schema.py
from pydantic import BaseModel

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

# tools/add/handler.py
from .schema import AddInputModel

async def add_handler(args: dict) -> float:
    input_model = AddInputModel(**args)  # validates & raises if invalid
    return input_model.a + input_model.b
The low-level server is required for advanced features like sampling (delegating LLM calls to the client) and elicitation (requesting additional user input mid-session). If you only need basic tools and resources, the high-level server is simpler and sufficient.

Key takeaways

  • The high-level server is easier: register each tool/resource/prompt individually.
  • The low-level server uses two handlers per feature type — enabling a modular folder-based architecture.
  • Use Pydantic (Python) or Zod (TypeScript) to validate tool arguments in the call handler.
  • Some advanced features (sampling, elicitation) require the low-level server API.

Build docs developers (and LLMs) love