How to Build a Custom MCP Server with Python: A FastMCP Walkthrough (2026)

Skip the boilerplate. Learn how to ship a dual-transport FastMCP server with typed tools, live resources, and prompt templates that any MCP-compatible agent can call.

SB

SmartBuddy Engineering Team

Autonomous Systems & Developer AI & MCP Skills
How to Build a Custom MCP Server with Python: A FastMCP Walkthrough (2026)

⚑ Key Takeaways

  • Three primitives, one decorator pattern: FastMCP servers expose @mcp.tool() actions, @mcp.resource() read-only data URIs, and @mcp.prompt() templates β€” no custom protocol handling required.
  • Dual transport is not optional: a server that only runs stdio works in Claude Desktop but breaks the moment you need a remote microservice; production code needs both stdio and Streamable HTTP / SSE.
  • Type safety comes from Pydantic and Zod, not from hoping the caller sends the right shape β€” every parameter needs a Field() or z.object() schema with a description.
  • The skill outputs copy-paste client configs for claude_desktop_config.json, Cursor, and Claude Code CLI, so the last mile of wiring a server into an actual client isn't left as an exercise for the reader.

Why "It Works on My Machine" Isn't Enough for MCP Servers

Most developers who try to build a custom MCP server with Python start with a single tool wrapping one API call, then hit the same wall a week later: the code that worked fine on their laptop can't be reached by anything else. A weather API wrapper that only runs over stdio is fine for testing inside Claude Desktop. It falls apart the moment a second developer needs to hit the same server from a remote agent runtime, because stdio assumes a single local process talking to a single local client over standard input and output. There's no port, no URL, nothing to point a second client at.

The fix isn't complicated, but it's easy to skip under deadline pressure: build the transport switch in from the start. The FastMCP Server Builder skill treats this as a hard rule, not a nice-to-have, Phase 2 and Phase 3 of its workflow both generate an entrypoint that reads MCP_TRANSPORT from the environment and branches between stdio and sse/streamable-http. Miss that branch and you've built a demo, not a server.

The Three MCP Primitives, and When to Use Each

Before writing a line of server code, the skill's Phase 1 partitions whatever you're exposing, a database, a CLI tool, a REST API, into exactly three primitive types. Getting this split right up front saves a rewrite later, because tools, resources, and prompts aren't interchangeable.

Primitive Decorator What it does Example from the skill
Tool @mcp.tool() Executes an action, returns text/image/JSON execute_query() runs a parameterized SELECT against a Postgres replica
Resource @mcp.resource() Exposes read-only state at a URI, no side effects schema://main returns the live database DDL
Prompt @mcp.prompt() Reusable slash-command template with injected context query_assistance_prompt(table_name) builds a safe-SQL prompt scoped to one table

The distinction that trips people up is Tools versus Resources. If the client is asking "give me this data" with no side effects, that's a resource, cacheable, safe to poll. If the client is asking the server to do something (run a query, hit an API, write a file), that's a tool, and it needs the validation and error-guardrail treatment described below.

Step by Step: How to Build a Custom MCP Server with Python

Here's a trimmed version of the pattern the skill generates for Phase 2, a FastMCP server with one tool, one resource, and one prompt, wired for both local and remote use:

import os
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel, Field

mcp = FastMCP("DatabaseOps", dependencies=["psycopg2-binary"])

class QueryParams(BaseModel):
    query: str = Field(description="Safe parameterized SELECT query")
    limit: int = Field(default=50, ge=1, le=500, description="Max rows to return")

@mcp.tool()
async def execute_query(params: QueryParams, ctx: Context) -> str:
    """Execute a read-only SQL query against the analytics replica.

    Real safety requirements, not just the docstring's claim: the DB
    connection uses a SELECT-only role enforced at the database level,
    `params.query` is checked against an allowlist/parsed-SQL policy (no
    DDL/DML, no stacked statements), a query timeout and the `limit` cap
    are enforced server-side, and every query is logged for audit purposes.
    """
    ctx.info(f"Executing query: {params.query[:50]}...")
    return f"Executed: {params.query} (Limit: {params.limit})"

@mcp.resource("schema://main")
def get_database_schema() -> str:
    """Return the DDL schema for tables this server is authorized to expose, never the full database schema, and never other schemas, credentials, or connection strings."""
    return "CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT, created_at TIMESTAMPTZ);"

if __name__ == "__main__":
    transport = os.getenv("MCP_TRANSPORT", "stdio").lower()
    if transport in ("sse", "streamable-http"):
        port = int(os.getenv("PORT", 8000))
        mcp.run(transport="sse", port=port)
    else:
        mcp.run(transport="stdio")

Two details matter more than they look. First, QueryParams.limit caps out at le=500, an unbounded limit field is how a "read-only" tool ends up dumping an entire table into a chat context. Second, the transport check happens once, at process start, reading an environment variable rather than a hardcoded flag, that's what lets the identical script run inside Claude Desktop today and behind an SSE endpoint on a cloud box tomorrow, with no code change.

stdio vs. Streamable HTTP / SSE: Picking the Right Transport

stdio Streamable HTTP / SSE
Where it runs Local process, spawned by the client Remote server, listens on a port
Typical client Claude Desktop, Cursor, local Claude Code sessions Cloud agent runtimes, multi-user microservices
Config surface command + args in the client's MCP config url + auth headers
Failure mode if skipped N/A, this is the default Server can't be reached by anything outside the host machine
Skill's guarantee Generated by Phase 2/3 as the else branch Generated by Phase 2/3 as the if transport in (...) branch

Neither one is "better", they answer different questions. stdio answers "can a single local client talk to this process," and Streamable HTTP / SSE answers "can multiple remote clients share this service." The skill's Core Rule #3 (Dual-Transport Implementation) exists specifically so you don't find out the hard way, mid-deployment, that you only built half the answer.

Security Guardrails: What the Skill Refuses to Generate

Phase 4 of the workflow enforces three invariants on every server it produces, and they're worth internalizing even if you write the code by hand:

  1. Strict input validation. Every tool parameter carries an explicit Pydantic Field or Zod schema with a description, not an untyped dict or any.
  2. No hardcoded secrets. Credentials load from environment variables (os.environ["API_KEY"]), never inline in the tool body.
  3. Clean error propagation. Tools return an MCP error payload (isError: true) instead of letting an unhandled exception crash the stdio process and take down the whole client session.

That third one is the one people skip. A single unguarded except block missing from a tool means one malformed request from an LLM, which happens more often than you'd like, kills the entire server process, not just that one call.

TypeScript, for the Node-First Teams

The same three-phase logic applies on the TypeScript side using @modelcontextprotocol/sdk and zod instead of mcp.server.fastmcp and Pydantic. A GitHub issue tool, for instance, declares its shape with z.string() and z.number().min(1).max(7) the same way the Python version uses Field(ge=1, le=500), the validation philosophy doesn't change, only the syntax. The skill's Phase 3 output wires an Express app behind /sse and /messages routes for the remote case, and falls back to StdioServerTransport otherwise, mirroring the Python if/else branch, though a production SSE deployment can't stop there: it needs per-client session isolation (no single shared/global transport variable, which is how one client ends up able to hijack another's session) and a requireAuth check in front of both routes.

From Generated Code to a Working Client Config

A server nobody can point a client at isn't shipped yet. Phase 5 closes that gap by generating the JSON block a developer pastes straight into their client:

{
  "mcpServers": {
    "database-ops": {
      "command": "python",
      "args": ["-m", "servers.database_ops"],
      "env": { "DATABASE_URL": "${DATABASE_URL}" }
    }
  }
}

Note what's absent: no hardcoded connection string. ${DATABASE_URL} gets resolved from the environment the client is launched in, which keeps the guardrail from Phase 4 intact all the way through to the config file.

Frequently Asked Questions

What is FastMCP vs. the standard MCP SDK?

FastMCP is a decorator-based framework β€” @mcp.tool(), @mcp.resource(), @mcp.prompt() β€” that wraps the lower-level MCP SDK and adds automatic Pydantic (Python) or Zod (TypeScript) validation, cutting out most of the boilerplate you'd otherwise write by hand.

Do I need to choose between stdio and SSE, or can a server support both?

A single server can support both β€” the skill generates one entrypoint that reads an MCP_TRANSPORT environment variable and branches accordingly, so the same codebase runs locally via stdio and remotely via Streamable HTTP / SSE without a rewrite.

Which AI coding assistants can run this skill?

The skill is verified for Claude Code, Cursor, Windsurf, Gemini CLI, Google Antigravity, and OpenHands β€” copy SKILL.md into whichever tool's agent-skills directory and invoke it with a natural-language prompt describing the server you want.

Did you find this technical breakdown helpful?

Tap to rate this guide · 21 views

Comments

Comments are reviewed before appearing publicly.

No comments yet β€” be the first.

πŸš€ Ready to Deploy Autonomous Skills in Production?

Get this skill (and 29 more) in the SmartBuddy Shop, or work with our engineering team to architect custom multi-agent workflows for your company.