Publishing 2 Sep 2026 · 4 min read

How to build an MCP server in Python with FastMCP

A decorator turns a function into a tool, and that is genuinely most of it. The rest — docstrings a model can act on, errors, the HTTP transport and a package uvx can run — is what this covers.

MR mcprush team · The people who run the gateway

Python is where most MCP servers get written first, and FastMCP — the high-level server in the official SDK — is why: a typed function with a docstring becomes a tool, the schema is read off the signature, and the protocol is somebody else’s problem. The trap is the same one every easy path has. Because the tool is a function, people write it like a function, and the model is not a caller of functions. It is a reader of text. The TypeScript and C# guides build the same server.

What the model actually receives from your decorated function: a name, a description and a schema. The body is invisible to it.What the model actually receives from your decorated function: a name, a description and a schema. The body is invisible to it.
What the model actually receives from your decorated function: a name, a description and a schema. The body is invisible to it.

The minimum server

from mcp.server.fastmcp import FastMCP mcp = FastMCP("issues") @mcp.tool() def create_issue(project: str, title: str) -> str: """Creates one issue in an existing project and returns its key. Fails if the project does not exist; does not create it. Use list_projects to find the keys that exist.""" issue = api.create_issue(project, title) return f"Created {issue.key}: {issue.url}" if __name__ == "__main__": mcp.run(transport="stdio")
The signature is the schema. The docstring is the description. Both are read by the model on every turn.

Run it with uv run server.py and point a client at that command. Over stdio the client starts the process and owns its stdin and stdout, so the one rule is the one Python makes easy to break: a print() anywhere is a corrupted frame. Log to stderr, or through the context object below, and the "server disconnected unexpectedly" reports stop.

The docstring is the product

FastMCP turns the docstring into the tool description and the type hints into the argument schema, which means the quality of your tool is the quality of a comment most people write last. Three sentences do the work: what it does to the world, what has to be true first, and how it fails. Argument descriptions come from Field, and the model reads those too.

from pydantic import Field @mcp.tool() def list_open_issues( project: str = Field(description="Project key, e.g. INFRA"), limit: int = Field(default=20, le=100, description="Rows to return; pass cursor for the rest"), cursor: str | None = Field(default=None, description="From the previous page"), ) -> dict: """Lists open issues in one project, newest first. Read-only. Returns at most `limit` rows and a cursor when there are more.""" ...
A page size and a cursor on every list tool. A model will otherwise ask for everything, and everything lands in its window.
  • Verbs on nouns. create_issue, list_open_issues, close_issue. The model chooses by name first and description second.
  • Say "read-only" where it is true. It is the first thing a careful reader sorts your tools by, and any static check will compare it with what the code reaches.
  • Never address the model. "Always call this first" is not documentation; it is an instruction with your name on it, and anyone auditing the server looks for exactly that sentence.

Failing so the model can report it

An exception in a tool becomes an error result on the wire, which is fine for a bug and wrong for a fact about the world. A project that does not exist is not a bug. Return the sentence — FastMCP wraps a raised ToolError as a tool-level error the model reads as text — and name the way out.

from mcp.server.fastmcp.exceptions import ToolError @mcp.tool() def close_issue(key: str) -> str: """Closes one issue. Irreversible. Fails if the key is unknown or already closed.""" issue = api.get(key) if issue is None: raise ToolError(f"No issue called {key}. list_open_issues shows the keys that exist.") if issue.closed: raise ToolError(f"{key} is already closed; nothing was changed.") api.close(key) return f"Closed {key}."
Each failure names what to do next. "Irreversible" in the docstring is what stops a model calling it to see what happens.

Resources and prompts, and when not to bother

FastMCP gives you two more decorators. A resource is data at a URI that the client — not the model — chooses to attach: a file, a record, a template like issues://{project}. A prompt is a reusable message the user picks by hand, the slash-command shape. Both are real and both are supported unevenly: coding agents mostly ignore resources, and prompts appear only where the client has a menu to put them in.

@mcp.resource("issues://{project}/open") def open_issues(project: str) -> str: """Open issues in one project, as a plain list.""" return "\n".join(f"{i.key} {i.title}" for i in api.open(project)) @mcp.prompt() def triage(project: str) -> str: """Walk the open issues of one project and propose a priority for each.""" return f"Read the open issues in {project} and suggest a priority for each, with one reason."
A resource the client attaches; a prompt the user picks. Neither is called by the model on its own.

The rule: if you are not sure, make it a tool. A tool works in every client. A resource works in the ones that show resources, and a server whose main value is a resource is invisible in the rest.

Logging and progress, without touching stdout

The context object is how a tool talks to the client about anything other than its result: log lines, progress on a long call, and — in clients that support it — asking the model for a completion. It is the correct home for everything you were about to print().

from mcp.server.fastmcp import Context @mcp.tool() async def import_issues(csv_url: str, ctx: Context) -> str: """Imports issues from a CSV at a URL. Slow: one call per row.""" rows = await fetch_rows(csv_url) for n, row in enumerate(rows, 1): api.create_issue(row["project"], row["title"]) await ctx.report_progress(n, len(rows)) await ctx.info(f"imported {len(rows)} rows") return f"Imported {len(rows)} issues."
Progress is a protocol message. A client that shows it shows it; one that does not ignores it; neither breaks.

The same server over Streamable HTTP

When the server should hold the credential rather than the user’s laptop, or when there is anything to meter, it moves behind an address. In the Python SDK that is one argument: the transport. What changes is everything around it — the server is now reachable by strangers, so it needs authentication, a body-size limit and a refusal for anything that is not a request, before any tool runs.

if __name__ == "__main__": # stdio for a local install; streamable-http behind an address mcp.run(transport="streamable-http") # serves /mcp on the configured host and port
One line. The authentication in front of it is not one line, and it is not optional once there is an address.

Test it with the inspector, then package it

npx @modelcontextprotocol/inspector uv run server.py
Call every tool by hand with an argument that does not exist before a model ever does.

The install line people expect for a Python server is uvx <package>, and that works only when the package declares a console script. If the script name differs from the package name — it often does — the line is uvx --from <package> <script>, and a README that prints the short form prints one that says "no executable found".

[project] name = "issues-mcp" version = "1.0.0" dependencies = ["mcp>=1.8"] [project.scripts] issues-mcp = "issues_mcp.server:main"
The console script is the install line. Name it what you want people to type.

The checklist

Before you publishWhy
No print() anywhere on the stdio pathOne is a broken frame and a "disconnected" report
Every docstring says what it does, what must exist, how it failsThat text is the model’s only evidence
Reads and writes are separate toolsReaders sort by reach; credentials are scoped by it
List tools take a limit and a cursorOtherwise everything lands in the window
The console script exists and matches the install lineuvx runs a script, not a package
Credentials come from named environment variablesA server that starts without them fails later, and later is worse

The decorator was the easy part, and it should be. What a Python server gets wrong is almost never Python.