Aditya Sharma

How to Build an MCP Server in Python: Tested on SDK 2.3.0

On this page, 10 sections

You can build an MCP server in Python in 56 lines with the official SDK. Decorate a function with @mcp.tool(), call mcp.run(), and any MCP host can launch the file and call the function.

I built one on 11 October 2026 on Python SDK 2.3.0: two tools, one resource and one prompt. I ran it from a Python client and from Claude Code, and I pasted the real output below. The server checks post titles and counts words, because those are the checks I run before I publish.

If you still need the definition first, read what an MCP server is, where I logged a session message by message.

Set up

I made a fresh virtual environment and installed the mcp package from PyPI. The version that resolved was 2.3.0. Check yours with pip show mcp, because the import paths below are from that version and the SDK has changed between major versions.

The server

import logging
import re
from typing import TypedDict

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

logging.basicConfig(level=logging.INFO)  # logging goes to stderr
log = logging.getLogger("post-checks")

mcp = MCPServer("PostChecks")


class TitleCheck(TypedDict):
    length: int
    limit: int
    ok: bool


RULES = """Title: 70 characters or fewer.
SEO title: 60 characters or fewer.
Meta description: 120 to 160 characters.
"""


@mcp.tool()
def count_words(html: str) -> int:
    """Count the words in an HTML post body, ignoring tags."""
    text = re.sub(r"<[^>]+>", " ", html)
    words = len(text.split())
    log.info("counted %d words", words)
    return words


@mcp.tool()
def check_title(title: str, limit: int = 60) -> TitleCheck:
    """Check an SEO title against a character limit."""
    if limit < 1:
        raise ToolError("limit must be 1 or more")
    return {"length": len(title), "limit": limit, "ok": len(title) <= limit}


@mcp.resource("rules://seo")
def seo_rules() -> str:
    """The length rules I check posts against."""
    return RULES


@mcp.prompt()
def review_post(html: str) -> str:
    """Ask for a review of a post against the length rules."""
    return f"Review this post against these rules:\n\n{RULES}\n\n{html}"


if __name__ == "__main__":
    mcp.run()

Save it as server.py. I ran python -m py_compile server.py and it exited 0.

What each part does

  • Tool: @mcp.tool() turns a function into something the model can call. The type hints become the input schema and the docstring becomes the description the model reads.
  • Resource: @mcp.resource("rules://seo") exposes read-only data at a URI. The host decides when to read it.
  • Prompt: @mcp.prompt() exposes a reusable template that a user picks, rather than something the model calls on its own.
  • Run: mcp.run() with no arguments starts a stdio server. The host launches the file as a child process and talks to it over stdin and stdout.

One detail cost me a failed first attempt. I wrote check_title with a bare -> dict return and got no structured output back. Returning a TypedDict, as above, gave me a typed result. I did not test other return shapes such as dataclasses or Pydantic models.

Test it with the in-memory client

The SDK ships a Client that can launch a stdio server, so you can test without any host. This is the client I used.

import sys

import anyio

from mcp import Client, StdioServerParameters


async def main() -> None:
    params = StdioServerParameters(command=sys.executable, args=["server.py"])
    async with Client(params) as client:
        print("capabilities:", client.server_capabilities.model_dump(exclude_none=True))
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        r = await client.call_tool("count_words", {"html": "<p>One two <b>three</b></p>"})
        print("count_words ->", r.structured_content, "isError:", r.is_error)
        r = await client.call_tool("check_title", {"title": "Do You Need an XML Sitemap? What Google's Docs Say", "limit": 60})
        print("check_title ->", r.structured_content)
        r = await client.call_tool("check_title", {"title": "x", "limit": 0})
        print("check_title(limit=0) ->", r.is_error, r.content[0].text)
        res = await client.read_resource("rules://seo")
        print("resource ->", res.contents[0].text.splitlines()[0])
        p = await client.get_prompt("review_post", {"html": "<p>Hi</p>"})
        print("prompt role ->", p.messages[0].role)


anyio.run(main)

Real output from the run on 11 October 2026, exit code 0. The two INFO lines are the server’s log output on stderr.

INFO:post-checks:counted 3 words
INFO:mcp.server.mcpserver.server:Tool 'check_title' failed: 'Error executing tool check_title: limit must be 1 or more'
capabilities: {'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
tools: ['count_words', 'check_title']
count_words -> {'result': 3} isError: False
check_title -> {'length': 50, 'limit': 60, 'ok': True}
check_title(limit=0) -> True Error executing tool check_title: limit must be 1 or more
resource -> Title: 70 characters or fewer.
prompt role -> user

Three things to read in that output. A bare int return comes back wrapped as {'result': 3}. Raising ToolError gives the client isError: True with your message, instead of crashing the server. The title I tested is 50 characters, so it passes a limit of 60.

Do not print to stdout

On stdio, stdout is the wire. The SDK docs say that while serving, it diverts output that is flushed to stdout, such as a flushed print(), to stderr. They also say that output flushed before serving begins, or a print() that stays buffered until the interpreter exits, can still land on the wire and corrupt a message.

I tested the first case only. I added print("debug: counting", flush=True) inside count_words and ran the same client. The line showed up on my terminal as stderr, every call still returned, and the exit code was 0. I did not test import-time prints or buffered prints, so I cannot show you the failure the docs describe.

My rule is simpler than the docs: use logging, as in the server above, and never print.

Connect it to Claude Code

I used a config file rather than editing my main setup. It names the server and gives the absolute path to the venv’s Python and to server.py.

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

I passed that file to a non-interactive claude -p run with --mcp-config and --strict-mcp-config, so only this server loaded. The Claude Code docs say --strict-mcp-config makes it use only the servers you pass with --mcp-config.

The run used the title “Do You Need an XML Sitemap? What Google Docs Say”. It took 3 turns and 10,482 ms, and it reported a cost of $0.1443. The transcript shows two tool calls: a ToolSearch for mcp__postchecks__check_title, then the call itself. The result was {"length":48,"limit":60,"ok":true}.

The ToolSearch step is Claude Code’s default behaviour. The docs say tool search defers MCP tool definitions so that only tool names load at session start. This is one run, not a benchmark, and the cost depends on the model and context of that session.

Claude Code also limits what a tool can send back. The docs say it warns when output passes 10,000 tokens, caps it at 25,000 tokens by default, and saves text results longer than 50,000 characters to a file. For a tool that returns a whole post, keep the output short. If you also use hooks to guard what Claude Code does, I wrote how Claude Code hooks work.

Moving from stdio to HTTP

The SDK’s run page lists two transports for new work: stdio for local subprocess servers, and streamable-http for anything you deploy. It says SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision and tells you not to build anything new on it.

Per that page, the change is one line: mcp.run(transport="streamable-http", port=3001), with the endpoint at /mcp by default. I did not run this, and a public server needs authentication that I did not build. For WordPress-specific servers that already exist, see MCP servers for WordPress: what exists.

What I could not verify

  • Streamable HTTP: not run. The one-line change comes from the SDK docs.
  • The stdout failure cases the docs describe: I tested only a flushed print.
  • Other hosts such as Claude Desktop or an IDE: not tried.
  • The Claude Code run is a single run with one prompt, so the turns, time and cost are not typical figures.
  • The official build-a-server page on modelcontextprotocol.io returned an HTTP 500 to my crawler, so I used the SDK’s own docs and did not cite that page.

Do this today

Pick one check you repeat by hand, such as counting words or measuring a title. Write it as a plain function, add @mcp.tool(), and run the in-memory client against it before you connect any host.

Then confirm nothing in your server writes to stdout, and keep the tool’s output small.

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.