AI Engineering

Build an MCP Server in Python with FastMCP

A working MCP server in twenty lines, then the parts that only break in production: tool design, validation, error messages, testing and security.

Short answer: an MCP server is a small program that exposes tools, resources, and prompts to an AI client over a standard protocol. In Python you build one with FastMCP in about twenty lines: decorate a function, write a docstring the model can read, and call mcp.run(). The hard part is not the boilerplate. It is deciding what belongs in a tool, keeping responses small enough to be useful, and making failures legible to a model that cannot see your stack trace.

This guide builds a working server in that order, then covers the parts that only show up in production.

A usable MCP server exposes a small number of well-named tools with clear docstrings, validates every argument, and returns errors as text the model can act on. Tool count and description quality matter more than implementation cleverness, because the model chooses tools by reading their descriptions.

The three primitives, and when to use each

Primitive What it is Who triggers it Use it for
Tool A callable function with typed arguments The model Actions, queries, anything with side effects
Resource Read-only content addressed by URI The client or model Documents, config, records the model should read
Prompt A reusable message template The user or client UI Repeatable workflows, slash-command style entry points

Most first servers expose everything as a tool. That works, but it wastes context: tools are always visible in the catalogue, while resources are fetched only when needed. If something is pure reading, make it a resource.

Set up the project

mkdir docs-mcp && cd docs-mcp
uv init --no-workspace
uv add fastmcp

The standalone fastmcp package ships the current major version with the features described below. The official SDK also bundles a FastMCP class under a different import path, so if you already depend on mcp, check which one you have before copying code from any tutorial. The two are not interchangeable at import level.

A minimal server that actually does something

from fastmcp import FastMCP

mcp = FastMCP("docs-server")


@mcp.tool()
def search_docs(query: str, limit: int = 5) -> list[dict]:
    """Search internal documentation and return the best matching passages.

    Use this when the user asks about internal processes, deployment steps,
    or anything that lives in the engineering handbook.
    """
    hits = index.search(query, k=min(limit, 20))
    return [
        {"title": h.title, "url": h.url, "text": h.text[:800]}
        for h in hits
    ]


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

That is a complete, runnable server over stdio. Three things are doing the real work:

  • The function name becomes the tool name. Keep it a verb phrase.
  • The docstring becomes the description. This is the model’s only documentation.
  • The type hints become the input schema. limit: int = 5 gives a default the model can omit.

Write docstrings for a model, not for a linter

This is the single highest-leverage thing in the file. A model picks tools by reading names and descriptions, then fills arguments from the schema.

A weak docstring:

"""Search docs."""

A useful one states when to use it, what it returns, and what it does not cover:

"""Search internal documentation and return the best matching passages.

Use this for questions about deployment, on-call process, and architecture
decisions. Do NOT use it for customer account data - use lookup_account.

Returns a list of {title, url, text}. Text is truncated to 800 characters;
call fetch_doc with the url if you need the full document.
"""

That last sentence prevents a whole class of failure. Without it, the model assumes it has read the document and answers from a fragment.

Resources for read-only content

@mcp.resource("docs://guides/{slug}")
def get_guide(slug: str) -> str:
    """Return a full engineering guide by slug."""
    path = Path("docs") / f"{slug}.md"
    if not path.exists():
        raise ValueError(f"No guide named {slug}")
    return path.read_text(encoding="utf-8")

URI templates give the client a predictable address space. Keep the scheme stable — clients cache resources by URI, and renaming a scheme invalidates every cached entry.

Validate inputs and fail loudly

Models produce plausible-looking arguments that are wrong in boring ways: an empty string, a limit of 5000, a date in the future. Validate at the boundary.

from pydantic import BaseModel, Field, field_validator

class SearchInput(BaseModel):
    query: str = Field(min_length=2, max_length=200)
    limit: int = Field(default=5, ge=1, le=20)

    @field_validator("query")
    @classmethod
    def not_blank(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("query must contain visible characters")
        return v.strip()

Then raise ordinary exceptions in the handler. A validation error message like limit must be between 1 and 20 is something the model can correct on the next turn. A 500 with a stack trace is not.

The error rule

Every error string should answer two questions: what went wrong, and what should the model try next. Write them as instructions, not as diagnostics.

Bad Good
KeyError: 'prod' Unknown environment 'prod'. Valid values: staging, production.
Timeout after 30s Search timed out. Retry with a shorter query or fewer results.
403 This token can read staging only. Ask the user for production access.

Test it without a chat client

Two layers worth having. First, an in-memory client test so the handler logic is covered by normal CI:

import pytest
from fastmcp import Client

@pytest.mark.asyncio
async def test_search_returns_results():
    async with Client(mcp) as client:
        result = await client.call_tool("search_docs", {"query": "deploy"})
    assert result

Second, a manual pass through MCP Inspector to check what a model actually sees: tool list, descriptions, schema shape. Bugs that pass unit tests still show up here, usually as a tool description that reads fine to a human and uselessly to a model.

The client API has moved between major versions, so if the snippet above does not match your installed version, trust the release notes over this page.

Going remote

Stdio is fine for local tools. The moment a server is shared, it needs HTTP, authentication, and statelessness.

if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8000)

Under the 2026-07-28 revision of MCP, that deployment is meaningfully simpler than it used to be: there are no sessions to stick, so any instance can serve any request. You still have to send the routing headers and handle authorization. We covered the protocol side in the 2026-07-28 migration guide, including Mcp-Method and Mcp-Name.

Production security checklist

  1. Assume the model is an untrusted caller. It will pass user text straight into your arguments.
  2. Never interpolate arguments into SQL, shell, or file paths. Allowlist paths, use parameters, exec with argument arrays.
  3. Scope credentials per tool, not per server. A read tool should not carry a write token.
  4. Rate limit per caller, using the routing headers rather than body parsing.
  5. Cap response size. A tool that returns 2 MB of JSON burns context and money on every call.
  6. Log tool calls with arguments, minus secrets. When something goes wrong, this is the only record of what the model did.
  7. Make destructive tools require confirmation. Under the new spec, that is an MRTR flow rather than a prompt you hope the model respects.

Common mistakes

Mistake What happens Fix
One tool per REST endpoint Catalogue bloat, model picks wrong tool Group by user intent, not by API route
Docstring repeats the name Model has no guidance on when to use it State the trigger condition
Returning raw DB rows Context flooded, cost spikes Project to the fields that answer the question
Raising bare exceptions Model retries the same wrong call Return an actionable error string
State in the module global Breaks under concurrency, breaks on restart Pass handles explicitly
No test for argument validation First real failure is in production Add negative cases to CI

Frequently asked questions

How many tools should one server expose?

Enough to cover one job, few enough that the descriptions fit comfortably in context. In practice that is usually five to fifteen. Past twenty, models start picking the wrong tool far more often.

Should I use stdio or HTTP?

Stdio for a local, single-user tool. HTTP as soon as the server is shared, remote, or needs auth. There is no benefit to HTTP for a personal filesystem helper.

Can one server expose both tools and resources?

Yes, and most good ones do. Read paths as resources, actions as tools.

Do I need to implement authorization myself?

For a local stdio server, no. For anything reachable over HTTP, yes — the protocol defines the mechanism but not your policy.

How do I stop the model from calling a dangerous tool?

Do not ship it in the catalogue, or gate it behind a confirmation flow. A description that says “be careful” is not a control.

Does this work with a stateless deployment?

Yes, that is the point of the 2026-07-28 revision. Keep application state in explicit handles the model passes back, not in module globals.

Sources

Model Context Protocol specification and Python SDK documentation, modelcontextprotocol.io, checked 22 October 2026. FastMCP project documentation, checked the same day. Code samples are written against the current major version; verify import paths against your installed release before deploying.

Last updated 22 October 2026.