How to Build a Python MCP Server With Tool Calls
Learn FastMCP's decorator pattern to wire Python functions as callable tools for AI agents.

How to Build a Python MCP Server With Tool Calls.
Tool calls and their role in MCP
This piece is about building a Python MCP server that exposes callable tools to AI agents, covering the protocol structure, defining tools correctly, and wiring up transport. The growth curve since that release has been steep. By March 2026, the protocol was logging 97 million monthly SDK downloads and more than 81,000 GitHub stars, with Anthropic, OpenAI, Google, Microsoft, and AWS all supporting it Complete Guide to MCP (Model Context Protocol) in 2026. The server count tells a similar story, with the ecosystem passing 13,000 servers in 2026 Build MCP Server From Scratch Guide.
The "USB-C for AI" comparison is useful shorthand; the real point is standardization across the major vendors.
Tools are executable functions the AI can trigger, such as fetching weather, running a SQL query, or sending email, and the AI decides when to invoke them based on the prompt. Resources are read-only data the model can pull in for context: files, database tables, live API responses. Prompts are templates that pin down how the agent should behave in a given situation.
Of the three, tools carry the most weight, and not by a small margin. Every MCP client supports tool calls; resources and prompts are optional extensions that a given client may or may not implement. So if a server is going to be usable across the widest range of hosts, tools are the one primitive that can't be skipped.
The mechanics are simple enough to hold in your head. A server advertises what it can do through a list_tools call, and each entry in that catalog carries a name, a description, an input schema, and an expected output shape. When the model decides a tool is needed, the client sends a call_tool request with the tool's name and arguments, the server runs the function, and the result comes back as structured data. The three server primitives (tools, resources, prompts) are briefly defined.
The client-server architecture
Picture the architecture as a single host app, something like Claude Desktop, Claude Code, or Cursor, spinning up multiple client sessions, each one holding a stateful JSON-RPC channel to its own MCP server. One host, many clients, many servers. None of them share a connection.
Three parties show up in this model, and each has a distinct job. The user or the AI agent issues the query. The MCP host wraps the LLM agent and handles the job of connecting to whatever servers are available when the session starts. And the servers themselves expose tool names, descriptions, and schemas, which then get folded into the model's context or system prompt so it knows what's on offer.
Here's the direction that trips people up the first time: the server never calls the model. It just sits there, waiting for a client to connect, answering discovery requests, and running whatever tool gets invoked. All the initiative comes from the client side. A server that tries to reach out and prompt the model on its own isn't following the protocol; it's doing something else.
MCP supports two transport paths: JSON-RPC 2.0 over stdio, or over Streamable HTTP. Stdio is the default for local development and works with Claude Desktop. Nothing about local development requires a network port; the client just launches the server as a subprocess and talks to it over standard input and output.
The 2026-07-28 spec dropped the old stateful handshake model. Before that spec, connecting meant an initialize/initialized exchange and a Mcp-Session-Id header that pinned a client to one specific server process. After it, there's no handshake. Every request describes itself through a _meta field carrying protocol version, client identity, and capabilities, and a server/discover RPC replaces the old capability negotiation. Servers that still need to track state across calls do it with explicit handles the server mints and hands back as tool arguments, rather than leaning on some protocol-level session object. Hold onto that detail. It matters more once a server needs to scale, and gets its full treatment later in this piece.
Environment setup and project structure before writing server code
Python 3.9 is the floor, but 3.11 is the version worth actually installing, mostly for the performance gains. Below 3.9, some of the typing features the SDK leans on aren't available, so don't bother trying to make an older interpreter work here Complete Guide to MCP (Model Context Protocol) in 2026.
For the SDK, FastMCP is the practical choice. It installs as fastmcp, no hyphen, and it gives a decorator-based interface that turns an ordinary Python function into a registered tool without much ceremony. It also handles the JSON-RPC plumbing that produces this decorator-based interface, so none of that has to be hand-rolled. Python isn't the only option, either. Go, C#, and TypeScript are all Tier 1 SDKs with full support for the 2026-07-28 spec. A stable Rust SDK exists too. This walkthrough sticks to Python because it's the fastest path from zero to a working tool call, but the architecture holds regardless of which SDK a team picks.
Project setup itself is quick. Anyone who'd rather stick with a plain virtual environment can run pip install "mcp[cli]" instead, same result.
Structure-wise, keep it minimal at first. A server.py file holds the server instance and the tool definitions, and a .env file holds API keys and other secrets, added to .gitignore before a single line of code goes into it. Resist the urge to split every tool into its own file or its own server. One server with five related tools is genuinely easier for an agent to reason about than five separate single-tool servers scattered across a project.
Skip authentication, database wiring, and cloud deployment for now. The goal at this stage is a server whose shape is proven to work, not a production system. As for how long this actually takes: on Python 3.11, building an add tool that runs over stdio and gets picked up by Claude Desktop takes about 14 minutes the first time through, and once the pattern is familiar, under 5 minutes How to Build an MCP Server (Python & TypeScript 2026). That's a useful number to keep in mind, because it means the setup phase should feel fast, not like a slog. Package setup with uv is now the standard for MCP Python projects per techsy.io.
Defining tools correctly: decorators, type hints, and docstrings as schema
Start the server file with an instance: mcp = FastMCP("demo-server"). That string is the name a connecting client will show in its tool list, so name it something a person would recognize later.
Every tool follows the same shape after that. Write a plain Python function, put @mcp.tool() above it, and it registers as an MCP tool. No manual schema file, no separate JSON to maintain by hand. The function's own signature does that job. Type hints like a: int, b: int get turned directly into the JSON Schema definition for the tool's inputs, and the docstring becomes the description the model actually reads when deciding whether to call the tool. Write that docstring like an instruction aimed at the model. A vague docstring is the single most common reason a model reaches for the wrong tool.
Three examples make the pattern concrete. An addition tool is the simplest case: synchronous, two integer parameters, one integer return. It exists to prove the decorator and the schema generation work, nothing more. A random word generator is barely more complex, useful mainly for showing that a tool doesn't need external state or dependencies to be valid. The one that actually resembles real work is a live weather lookup, which pulls from an external API. That one has to be async def rather than a plain function, because it's doing I/O and blocking the event loop while waiting on a network call would be wasteful. Ambiguous or deeply nested return types can get silently truncated by some clients, so flattening the output into readable text before returning it is worth the extra line.
Running the server locally is a single call: mcp.run() inside the if __name__ == "__main__": block, which defaults to stdio transport. During development, though, uv run mcp dev server.py is the better command to reach for, since the dev runner wraps the server with tooling meant for iterating on it rather than launching it cold.
The server exposes and executes. It never calls the model on its own. Every tool defined here just sits, waiting to be invoked. pamelafox.github.io recommends using async with httpx.AsyncClient(timeout=10) for external calls.
Error handling inside tool handlers
An exception that's allowed to propagate out of a tool handler becomes a raw connection failure or an unhandled 500 on the client side, and agent frameworks tend to handle that badly, often worse than the actual bug that caused it apigene.ai. That's the failure mode to design around from the first tool onward.
The fix is a specific flag. When something breaks inside a tool handler, the response should set isError to true and put a plain-language message in the text block that comes back. Why does the flag matter so much? Because a tool that raises doesn't raise at the client. It answers, with is_error set, and that distinction lets the model actually read the failure message and decide to try something else instead of getting stuck. An agent loop that just stalls on an unhandled exception is a dead end. One that gets a readable error can route around it.
The pattern in code is straightforward: wrap external calls and any I/O in try/except, catch specific exception types where it's practical rather than a bare except Exception, and always return a TextContent block, never an empty response and never a silently swallowed error. In a weather tool or a database tool specifically, a missing API key, a network timeout, and bad input from the caller are three failure cases that occur constantly enough to plan for by name. Handle those three explicitly and most of what breaks in practice gets covered.
Wiring up transport and connecting a client to the running server
Stdio remains the default path for local development, and it's what Claude Desktop, Claude Code, and Cursor all expect: the client process launches the server as a subprocess directly, no network port involved.
From there, the whole connection lifecycle lives inside an async with Client(...) block. Once inside it, await client.list_tools() returns the server's catalog, each entry carrying its name, description, and input schema.
Calling a tool from the client side is await client.call_tool(tool_name, tool_args), which returns a CallToolResult object whose content is a list of blocks. Narrow that list down to TextContent before reading .text off of it; skip that step and the code ends up handling a type it wasn't expecting.
Pass the available tools to the LLM through the tools parameter on the model call, and when the model's response comes back with tool_use content, extract the tool name and arguments, call the tool, and append the result back into the conversation. Then call the model again with that result folded in, so it can either answer the user directly or decide it needs to call another tool. That's the entire agent loop, in miniature, and it repeats as many times as the task demands.
HTTP transport becomes relevant once a server needs to be reachable across a network rather than launched as a local subprocess, and the 2026-07-28 spec requires OAuth 2.1 for any remote MCP server. That's a production concern more than a development one, so it gets picked back up later here rather than dwelt on now. A client connects using the official Python MCP SDK client pattern from modelcontextprotocol.io, for MCP spec version 2026-07-28. It requires Python MCP SDK 2.0.0 or higher. StdioServerParameters is a configuration describing which subprocess to launch, whether a Python or Node script. stdio_client() turns StdioServerParameters into a stdio transport.
Testing the server with MCP Inspector before connecting a live agent
MCP Inspector is the standard tool for debugging a server before anything else touches it. Run npx @modelcontextprotocol/inspector python server.py and it connects directly to the server, lists what tools are available, and lets a developer test individual calls with custom input by hand. It shows the full surface: tool schemas, resource URIs, prompt templates, everything the server is willing to expose to a client.
Why bother with this step before wiring up an actual model? Because Inspector, along with tools like MCPJam, verifies the server's surface without a model in the loop introducing its own layer of unpredictability. A tool call that fails inside a live agent session is genuinely hard to debug, since the failure could be the server, the model's tool selection, or the prompt itself. A tool call that fails inside Inspector has exactly one place the fault can live.
Tool names should match expectations, input schemas should reflect the type hints correctly, and docstrings should show up as the descriptions a model would actually read. Beyond that, happy-path calls should return clean output, and error-path calls, things like bad input or a missing API key, should come back with isError: true and a readable message rather than a raw exception dump.
Structured logging should be added before any of this testing starts, not after, so Inspector's output is actually legible when something goes sideways. And only once Inspector confirms the server surface looks right is it worth connecting to Claude Desktop or a full agent framework. Skipping that order just moves the debugging into a harder environment for no real benefit.
The 2026-07-28 spec's stateless model and its implications for servers built today
Circle back to the shift flagged earlier in the architecture section, because this is where it actually bites. The initialize/initialized handshake is gone, and so is the Mcp-Session-Id header. Every request now self-describes through a _meta field instead.
Under the old model, a client's session was pinned to one specific server process for the life of the connection, which meant sticky routing was mandatory anywhere a server ran behind a load balancer. Under the new one, there's no session to pin. Any request carries everything the server needs to know about who's asking and what they support, and a server/discover RPC handles capability negotiation in place of the old handshake. If a tool genuinely needs to track state across multiple calls, that state now lives in an explicit handle the server hands back to the client and the client passes forward as a tool argument, rather than something baked into the protocol layer itself.
The practical upshot is that a server built against this spec can sit behind a plain round-robin load balancer with no sticky session logic required at all. For anyone planning to run more than one instance of a server, that's not a minor implementation detail. It's the difference between infrastructure that scales the way a normal stateless web service does, and infrastructure that needs its own special-cased routing layer just to keep sessions from breaking. A tool built the way this piece walked through, decorated function, typed inputs, a docstring that reads like an instruction, and error handling that returns isError instead of raising, already fits that stateless model without any rework. The protocol did the harder part; writing the tool correctly the first time is what makes it worth deploying. SOURCE PAGES are what the pages behind the outline's links say.


