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.
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.
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")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."""
...- 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}."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."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."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 portTest it with the inspector, then package it
npx @modelcontextprotocol/inspector uv run server.pyThe 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 checklist
| Before you publish | Why |
|---|---|
| No print() anywhere on the stdio path | One is a broken frame and a "disconnected" report |
| Every docstring says what it does, what must exist, how it fails | That text is the model’s only evidence |
| Reads and writes are separate tools | Readers sort by reach; credentials are scoped by it |
| List tools take a limit and a cursor | Otherwise everything lands in the window |
| The console script exists and matches the install line | uvx runs a script, not a package |
| Credentials come from named environment variables | A 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.