Sam Austinon October 23, 202615 min read

Official MCP Python SDK: Low-Level Server Without FastMCP

Official MCP Python SDK: Low-Level Server Without FastMCP
Contents

Figure 1: Official MCP Python SDK: Low-Level Server Without FastMCP — the handshake, the tool list and the dispatcher

Most MCP tutorials start with a decorator: @mcp.tool(), and a function turns into a tool. It is neat, and it hides everything that happens underneath: how the server advertises its tools, how it dispatches calls, and how it talks to the client over stdio.

The official Python SDK also gives you a low-level Server class that exposes those pieces directly. You write the handlers, you describe the schemas, and you control the protocol surface. It takes more code, and it teaches you what the high-level helpers do for you.

This guide builds a small MCP server with the low-level API, explains when it is worth the extra work, and flags an important version warning. I researched it in October 2026, and numbered brackets like [1] point to the sources at the end. The code is my own, based on documentation snippets, and untested, so run it in a throwaway environment first.

First, Which "FastMCP" Do You Mean?

Two things share that name, and the confusion costs people hours [1]:

  • mcp.server.fastmcp.FastMCP ships inside the official mcp package, and the FastMCP project calls it "FastMCP 1.0" [1].
  • The standalone fastmcp package is a separate, more feature-rich project. It depends on the mcp package, so the SDK stays installed alongside it [1]. My earlier database tutorial used this one.

This guide uses neither. It uses the SDK's low-level Server class, which official docs describe as the option for more control [2].

Why Go Low-Level?

For most servers, a high-level framework is the better choice. Reach for the low-level API when you need:

  • Direct control of the protocol surface: one migration guide describes this as the whole point of the low-level API, and the right call for some servers [1].
  • Dynamic tool lists: tools that appear or change based on user, permissions or runtime state.
  • Custom lifecycle handling: the low-level server supports a lifespan hook for setup and teardown [2].
  • Custom capabilities and notifications: you declare exactly what the server supports.
  • Learning: you see the real handshake and message flow.

The trade-offs are real. A high-level framework removes boilerplate like manual list_tools and call_tool dispatching and initialization options [4]. With the low-level server, you write all of it. One real-world project that deliberately used the low-level class notes that it also loses the convenient mcp dev workflow for the MCP Inspector [4], and the SDK's own docs carry a caution to the same effect [2].

An Important Version Warning

The Python SDK has gone through a major version change. The FastMCP project's migration guides cover porting low-level servers from "SDK v1" and, in a separate guide, from "SDK v2," and they say the before-and-after code differs enough to warrant separate pages [1]. One guide even shows the error you get when v1-style code meets a newer SDK: AttributeError: 'Server' object has no attribute 'list_tools' [1].

In plain terms: the decorator-based low-level API shown below belongs to the v1.x line of the SDK. I found its documentation on the SDK's v1.x branch [2], and I could not verify the v2 low-level API in this research. So:

  1. Pin the SDK to a v1.x release while you follow this tutorial, for example with a version constraint like mcp>=1,<2. Check the package page for current versions.
  2. If you use v2, read the official upgrade notes and docs, because handler registration differs [1].
  3. Check the SDK repository (github.com/modelcontextprotocol/python-sdk) for the current state of both lines.

Setup

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "mcp>=1,<2" pydantic

My sample server stores short notes in memory. It is tiny on purpose, so the protocol mechanics stay visible.

Step 1: Describe Your Tools

In the low-level API, you register a handler that returns the tool list, and you write each tool's input schema yourself. A decorator @server.list_tools() marks that handler [2].

Pydantic can write the schemas for you, so you do not hand-craft JSON Schema:

from pydantic import BaseModel, ConfigDict, Field


class AddNoteArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str = Field(min_length=1, max_length=80)
    text: str = Field(min_length=1, max_length=2000)


class GetNoteArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str = Field(min_length=1, max_length=80)

My tool-call validation guide covers why extra="forbid" and bounded fields matter.

Step 2: Write the Server

Here is the full server. It follows the pattern in the SDK's low-level documentation: a Server object, @server.list_tools() and @server.call_tool() handlers, and a main() that runs the server over stdio with initialization options [2][3].

# notes_server.py
import asyncio
import sys

import mcp.server.stdio
import mcp.types as types
from mcp.server.lowlevel import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from pydantic import BaseModel, ConfigDict, Field, ValidationError

server = Server("notes-server")
NOTES: dict[str, str] = {}


class AddNoteArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str = Field(min_length=1, max_length=80)
    text: str = Field(min_length=1, max_length=2000)


class GetNoteArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str = Field(min_length=1, max_length=80)


@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="add_note",
            description="Save a note under a short title.",
            inputSchema=AddNoteArgs.model_json_schema(),
        ),
        types.Tool(
            name="get_note",
            description="Fetch a saved note by its exact title.",
            inputSchema=GetNoteArgs.model_json_schema(),
        ),
        types.Tool(
            name="list_notes",
            description="List the titles of all saved notes.",
            inputSchema={"type": "object", "properties": {}},
        ),
    ]


@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    try:
        if name == "add_note":
            args = AddNoteArgs.model_validate(arguments)
            NOTES[args.title] = args.text
            return [types.TextContent(type="text", text=f"Saved note '{args.title}'.")]

        if name == "get_note":
            args = GetNoteArgs.model_validate(arguments)
            if args.title not in NOTES:
                raise ValueError(f"No note titled '{args.title}'.")
            return [types.TextContent(type="text", text=NOTES[args.title])]

        if name == "list_notes":
            titles = sorted(NOTES) or ["(no notes yet)"]
            return [types.TextContent(type="text", text="\n".join(titles))]

    except ValidationError as exc:
        # compact message for the model; details go to stderr
        print(exc, file=sys.stderr)
        raise ValueError("Invalid arguments for " + name) from exc

    raise ValueError(f"Unknown tool: {name}")


async def main() -> None:
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="notes-server",
                server_version="0.1.0",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={},
                ),
            ),
        )


if __name__ == "__main__":
    asyncio.run(main())

What Each Piece Does

  1. Server("notes-server") creates the protocol handler and names it. A lifespan argument lets you run setup and teardown code around the server's life [2].
  2. @server.list_tools() answers the client's "what can you do?" request with a list of types.Tool objects.
  3. @server.call_tool() receives the tool name and an arguments dictionary. You dispatch by name, which is the if name == ... chain that high-level frameworks replace with one decorator per tool [1].
  4. stdio_server() opens the read and write streams that carry JSON-RPC messages over standard input and output [2][3].
  5. InitializationOptions declares the server's name, version and capabilities for the handshake [2][3].

Validate Arguments Yourself

The SDK's v1.x docs describe schema handling for tool inputs and for structured output, including validation against a tool's declared schema in some modes [2]. Do not rely on that alone. Validate with Pydantic inside the handler, as above, so your code enforces its own contract no matter what the client sends.

The Stdio Rule That Trips Everyone

Over stdio, standard output belongs to the protocol. If your code prints debug text with print(...), you corrupt the JSON-RPC stream, and the client fails with confusing errors. Send logs to standard error instead, as the sample does with file=sys.stderr, or use Python's logging module configured for stderr.

This is a general rule for stdio-based MCP servers, and the low-level API does not protect you from it. I did not re-verify it in this research, so confirm it in the transport documentation.

Step 3: Run and Test It

Start with the simplest check: run the file and confirm it starts and waits without crashing.

python notes_server.py

The process will sit silently, waiting for a client. Stop it with Ctrl+C.

Then test with a real client. Two cautions from the sources:

  • The SDK's docs warn that the mcp dev and mcp run helper commands do not support low-level servers [2][4].
  • You can still try the MCP Inspector by launching it manually against your script. I have not verified the exact command for the current Inspector version, so check the MCP documentation.

Or connect it to Claude Desktop through the config file, as in my guide on that topic:

{
  "mcpServers": {
    "notes": {
      "command": "/absolute/path/to/.venv/bin/python",
      "args": ["/absolute/path/to/notes_server.py"]
    }
  }
}

Ask the assistant to save a note, then list notes, then fetch one. Try a bad call too, like asking for a title that does not exist, and watch how the error surfaces.

Going Further With the Low-Level API

Once tools work, the same pattern extends to other MCP features. In v1.x, you register handlers with decorators in the same style, for example for resources and prompts, and you can declare structured output for tools [2]. Check the SDK docs for the current decorator names and signatures before you build, because I only verified the tool-related ones here.

Useful additions to consider:

  • Lifespan context: open a database pool at startup and close it at shutdown [2].
  • Authorization and permissions: wrap the handlers with checks, as in my security guide.
  • Dynamic tool lists: return different tools based on caller identity.
  • Structured results: return typed data alongside text, if your client supports it.

Low-Level vs High-Level at a Glance

Low-level Server High-level framework
Boilerplate Manual list, dispatch and initialization code A decorator per tool [1][4]
Schemas You write or generate them Inferred from type hints
Control Exposes the protocol surface [1] Hides it
Tooling mcp dev does not support it [2][4] mcp dev works
Stability Changed between SDK major versions [1] Same risk, better documented

Build your first server with a high-level framework, and write one low-level server just to learn the protocol. After that, choose per project.

Common Pitfalls

  1. Printing to stdout. It corrupts the protocol stream.
  2. Mixing SDK versions. v1-style decorators fail on a newer SDK [1].
  3. Confusing the two FastMCPs. Check which package your imports use.
  4. Trusting schema validation alone. Validate inside the handler.
  5. Forgetting the unknown-tool branch. Raise a clear error.
  6. Using mcp dev with a low-level server. The docs say it is unsupported [2].
  7. Skipping version pins. An unpinned install can break a working server.

One Subscription Instead of Five AI Tools

Partner offer

GPTAstra Max: one subscription instead of five AI tools

Chat model, image model and video tool behind one interface with a lifetime licence — one bill while you build servers that call them.

Get GPTAstra Max

Affiliate link — we may earn a commission at no extra cost to you.

Wrapping Up

The low-level API shows the MCP server for what it is: a handshake, a list of tools, and a dispatcher. Write one yourself, and the decorator-based frameworks stop feeling like magic.

Pin your SDK version, keep stdout clean, and validate every argument. Then decide whether the control is worth the boilerplate for your next server.

If you want the high-level path for comparison, MCP server development in Python builds the same idea with decorators, and testing MCP servers with the Inspector covers the client side you will want next.

Frequently Asked Questions

What is the difference between mcp.server.fastmcp.FastMCP and the fastmcp package?

They are two different things sharing a name. mcp.server.fastmcp.FastMCP ships inside the official mcp package, and the FastMCP project calls it FastMCP 1.0. The standalone fastmcp package is a separate, more feature-rich project that depends on mcp, so the SDK stays installed alongside it. Check your imports before following a tutorial, because the advice for one does not always apply to the other.

Should I use the low-level Server class or FastMCP?

Use the high-level framework for most servers. Reach for the low-level Server class when you need direct control of the protocol surface, tools that change based on user or runtime state, custom lifecycle handling through a lifespan hook, or exact control over declared capabilities. Expect to write the list handler, the dispatcher and the initialization options yourself, and expect to lose the mcp dev workflow that the high-level path gives you for free.

Which SDK version does the low-level decorator API belong to?

Version 1.x. The decorator style with @server.list_tools() and @server.call_tool() is documented on the SDK's v1.x branch, and its documentation lives there rather than on the current line. On a newer SDK, v1-style code fails with AttributeError because the Server object no longer has that attribute. Pin mcp to a v1 release such as mcp>=1,<2 while following this tutorial, and read the upgrade notes before moving.

Why does mcp dev not work with a low-level server?

Because the helper commands are built around the high-level server, which knows how to expose its tools to the Inspector automatically. The SDK documentation carries a caution to that effect, and projects that chose the low-level class report losing the workflow. You can still launch the Inspector manually against your script, but check the current Inspector documentation for the exact command rather than assuming it is unchanged.

How do I validate tool arguments in a low-level server?

Validate inside the handler with Pydantic. The SDK describes schema handling for tool inputs and structured output, including validation against a declared schema in some modes, but that is not a reason to trust the payload. Build a model per tool, call model_validate on the arguments dictionary, and return a compact error to the model while logging the detail to stderr. Your handler then enforces its own contract whatever the client sends.

Why does my stdio MCP server break when it prints to stdout?

Because stdout carries the JSON-RPC stream, so a stray print interleaves non-protocol text with messages and the client sees a parse failure it cannot explain. Send diagnostics to stderr instead, either with print to stderr or with the logging module configured for stderr. The low-level API does not protect you from this, and the same rule applies to every stdio-based MCP server.

Sources

  • [1] FastMCP documentation, "Upgrading from the Low-Level SDK v1" — low-level Server handlers, FastMCP 1.0 versus the standalone package, v1 versus v2 differences, the AttributeError example: gofastmcp.com
  • [2] Python SDK documentation, "Low-Level Server" on the v1.x branch — list_tools, call_tool, stdio_server, InitializationOptions, lifespan, structured output and the mcp dev caution: github.com/python-sdk v1.x docs
  • [3] PyPI, nacos-mcp-wrapper-python — a README excerpt showing the same low-level pattern with NotificationOptions, Server and InitializationOptions: pypi.org/project/nacos-mcp-wrapper-python
  • [4] Glama, Pollen Alert MCP Server — a project that deliberately uses the low-level Server class, with notes on the trade-offs versus FastMCP: glama.ai

What are You Looking For?

esc