How to Build Your Own MCP Server in Python (FastMCP, 2026): Expose Your Tools to Any Client — Cesar Ayala
← All posts

How to Build Your Own MCP Server in Python (FastMCP, 2026): Expose Your Tools to Any Client

Install the official Python SDK with pip install mcp, then use FastMCP: create mcp = FastMCP("name"), decorate a typed function with @mcp.tool() so its type hints and docstring auto-generate the schema, expose data with @mcp.resource(), and start it with mcp.run(transport="stdio"). Connect via claude mcp add, the desktop config, or the Agent SDK's mcp_servers.

What is MCP, and why build your own server?

MCP — the Model Context Protocol — is an open standard that separates providing context from talking to the model. You write a server once that exposes your tools and data, and any MCP client — Claude Code, Claude for Desktop, the Claude Agent SDK, and others — can use it without any bespoke glue.

To build one in Python: pip install mcp, then use FastMCP. Create mcp = FastMCP("name"), decorate a typed function with @mcp.tool() so its type hints and docstring auto-generate the schema, expose readable data with @mcp.resource(), and start it with mcp.run(transport="stdio"). Connect it via claude mcp add, the Claude for Desktop config, or the Agent SDK’s mcp_servers option — the client discovers your tools automatically after a restart.

The reason to build a server instead of hardcoding tools into one app is leverage. Before MCP, every framework had its own tool format — if you wanted your internal “look up a customer by RFC” function in three assistants, you wrote it three times. With MCP you write it once, behind a standard interface, and every compliant client speaks to it the same way: write once, any client uses it.

If you have not wired an agent to your own systems yet, start with how to connect an AI agent to your data and tools — it covers RAG versus tool calling and where MCP fits. This one is the hands-on build.

The three capabilities a server exposes: Tools, Resources, Prompts

An MCP server can expose three distinct capability types, and knowing which is which keeps your design clean:

  • Tools — functions the model can call, with user approval. This is action and live data: “look up this order”, “create a ticket”, “run this query”. Tools can have side effects.
  • Resources — file-like readable data the client can load into context: an API response, the contents of a file, a config blob. Resources are read-only reference material, not actions.
  • Prompts — reusable prompt templates the user can invoke by name, so common workflows do not get retyped every time.

The mental model: Tools do, Resources inform, Prompts guide. Most servers start with one or two tools and grow from there. Do not reach for all three on day one.

The three MCP capabilities

ToolsCallable functions with side effects — the model acts (with approval)
ResourcesRead-only file-like data loaded into context
PromptsReusable templates the user invokes by name
A server can expose any combination. Most start with a tool or two.

Install the SDK and spin up FastMCP: pip install mcp

The official Python SDK ships everything you need, including the high-level FastMCP API. Install it:

pip install mcp

(The TypeScript SDK, if you prefer Node, is @modelcontextprotocol/sdk — but the rest of this post is Python.)

FastMCP is the Pythonic layer inside the official SDK. Instead of hand-writing JSON-Schema for every tool, you write a normal typed function and let FastMCP read your type hints and docstrings to generate the schema. The minimal server is three lines:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

That "weather" is the server’s name — it is how clients identify it. Everything else hangs off that mcp object as decorators. There is no boilerplate router, no schema file, no registration list.

Define a tool with @mcp.tool() — type hints + docstring become the schema

Here is where FastMCP earns its keep. You write a function with typed parameters and a docstring, decorate it with @mcp.tool(), and the SDK turns your type hints into the input schema and your docstring into the tool description the model reads to decide when to call it.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

@mcp.tool()
def get_forecast(city: str, days: int = 3) -> str:
    """Get the weather forecast for a city.

    Args:
        city: Name of the city, e.g. "Guadalajara".
        days: Number of days to forecast (1-7).
    """
    # Call your real weather API here.
    return f"Forecast for {city}: sunny for the next {days} days."

Read what you got for free. The parameter city: str becomes a required string input; days: int = 3 becomes an optional integer with a default. The first docstring line is the description the model uses to decide whether this tool fits the request. You wrote zero schema — the type hints are the schema, so the schema and implementation never drift apart. Change the signature, the schema changes with it. That is the biggest reason to use FastMCP over the low-level server API.

A few practitioner notes. Keep the docstring precise — the model routes on it, so a vague description means it calls your tool at the wrong time. Prefer narrow, single-purpose tools over one god-tool with a mode argument; the model reasons about a focused tool far better. And return a plain string or simple serializable value — you are handing text to a language model.

What @mcp.tool() generates for you

  1. Type hints → input schemacity: str becomes a required string; days: int = 3 becomes an optional integer
  2. Docstring → tool descriptionThe model reads it to decide when to call the tool
  3. Return value → tool resultA plain string or serializable value handed back to the model
  4. No JSON-Schema by handSignature and schema can never drift apart
One typed function is the single source of truth for both the schema and the implementation.

Expose your data with @mcp.resource()

Tools are for doing. When you want to hand the client readable reference data — a file, a config, an API snapshot — you use a resource. You decorate a function with @mcp.resource() and give it a URI, using a scheme of your choosing:

@mcp.resource("config://app-settings")
def app_settings() -> str:
    """The current application settings as JSON."""
    return '{"region": "MX", "currency": "MXN", "timezone": "America/Mexico_City"}'

Resources can also be parameterized with a URI template, so one function serves a whole family of addressable items:

@mcp.resource("customer://{rfc}")
def customer_profile(rfc: str) -> str:
    """Return the profile for a customer by their RFC."""
    # Look up the customer in your database by rfc.
    return f"Profile for customer with RFC {rfc}"

The {rfc} placeholder in the URI becomes the function’s parameter. The client can request customer://XAXX010101000 and FastMCP routes it to your function with rfc filled in. Use resources for anything the model should read but never change — the read-only framing is doing real safety work for you.

Run it: mcp.run(transport=“stdio”) and stdio vs HTTP

To start the server, call mcp.run() and pick a transport:

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

The transport is how the client and server talk, and the choice is mostly local-versus-remote:

  • stdio — the client launches your server as a subprocess and talks to it over standard input/output. This is the default for local servers and what you want while developing. No ports, no network, no auth to configure.
  • HTTP-based transports (SSE / Streamable HTTP) — for remote servers the client reaches over the network. This is what you deploy when the server lives on another machine and multiple clients connect to it.

Start with stdio. It is the shortest path to a working server on your own laptop, and every client supports it. Reach for HTTP only when you actually need a networked, multi-client, or hosted server — and when you do, read the spec-migration note further down.

stdio vs HTTP transport

stdio (local)

  • Client launches your server as a subprocess
  • Talks over standard input/output
  • No ports, no network, no auth to wire up
  • Default for local dev and every client supports it
  • Start here

HTTP (remote)

  • SSE / Streamable HTTP transports
  • Client reaches the server over the network
  • For hosted, multi-client, remote deployments
  • Needs auth and a real API surface
  • Target the current spec (see below)
Pick stdio for local development; HTTP transports for remote, networked, multi-client servers.

Connect it to a client: Claude Code, Claude for Desktop, the Agent SDK

A server nobody can reach is useless. Here are the three connections you will actually use.

Claude Code — add the server from the command line:

claude mcp add weather -- python /path/to/weather_server.py

The part after -- is the command Claude Code runs to launch your server over stdio. Restart and your tools are discoverable. (New to Claude Code? See what is Claude Code and how to use it.)

Claude for Desktop — add the server’s command and args to the desktop config file, claude_desktop_config.json:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/path/to/weather_server.py"]
    }
  }
}

Restart Claude for Desktop and it launches your server as a subprocess, discovering the tools automatically.

The Claude Agent SDK — pass your server through the mcp_servers option when you configure the agent, so your programmatic agent gets the same tools your desktop client does. If you are building agents this way, build agents with the Claude Agent SDK walks through the full setup, and build an autonomous agent with Sonnet 5 shows the hand-rolled tool loop underneath it.

In all three cases the pattern is identical: point the client at the command that launches your server, restart, and the client discovers your tools by asking the server what it exposes. You never register tools on the client side — the server is the source of truth.

The connect-and-discover flow

Point the client at the launch command
Client launches your server (stdio subprocess)
Restart the client
Client asks the server what it exposes
Your tools are discoverable
Every client follows the same steps — point it at your server's launch command, and it discovers the tools for you.

Going remote? The 2026-07-28 spec note

Everything above targets a local stdio server, which is the right default. The moment you go remote — a hosted HTTP server that clients reach over the network — you are on a moving target. The MCP spec had a 2026-07-28 release (the SDK v2 rework toward the stateless HTTP spec), and remote servers should target the current spec rather than an older SSE flow.

I keep the migration details in a dedicated post so this build stays focused: migrate an MCP server to the 2026-07-28 stateless spec. If you are only building a local stdio tool server, you can safely ignore this section — stdio is stable and unaffected.

Security: it runs real code, so treat it like any API surface

This is the part people skip, and it is the part that bites. An MCP server runs real code and can reach real data. The model proposes a tool call; your server executes it. That makes your server an API surface with a language model as one of its callers — and the model is an untrusted, promptable planner sitting in front of your systems.

Treat it exactly like any other API you expose:

  • Authenticate. Especially for remote HTTP servers, do not ship an unauthenticated endpoint that runs code.
  • Least privilege. Scope each tool to the narrowest capability that does the job. A read-only lookup tool should not be able to write. Do not over-scope.
  • Validate inputs. The rfc, the city, the days — validate them as if they came from a hostile client, because effectively they can.
  • Do not over-expose tools. Every tool you add is attack surface. If the model does not need it, do not expose it.

I wrote a full treatment of this — threat model, patterns, and concrete mitigations — in how to secure MCP servers. Read it before you expose anything past your own laptop. The convenience of @mcp.tool() cuts both ways: it is just as easy to make a dangerous function callable in one line.

Build vs. reuse: when to write your own server

You do not always need to build. There is a growing ecosystem of published MCP servers for common systems — file systems, databases, popular SaaS APIs. If a maintained server already covers your integration, reuse it; you get updates and a hardened surface for free.

Build your own when the tool is yours — your internal API, your database schema, your business logic, the “look up a customer by RFC and check their fiscal status” operation that only exists inside your company. That is exactly the case FastMCP makes trivial: a typed function, one decorator, and every client you run can use it.

The decision rule: if the capability is generic and someone reputable maintains a server for it, reuse. If it encodes your domain — and most valuable ones do — build it. Either way the client integration is identical, which is the entire point of the standard.

To go deeper: what is an AI agent for the concept, how to use the Claude API for the raw Messages layer under all of this, and the AI agents hub for the full set. Sources: the official Build an MCP Server guide and the Python SDK on GitHub.