Blogs / Building an MCP Server in Python

Building an MCP Server in Python

Published
October 5, 2026
Author
Faizan Nadeem
Tags
MCP Python AI Agent Tools Backend Development
A dark workshop filled with hand tools hanging on a pegboard and wood pieces on a workbench
Photo by Minh Đức on Unsplash

Most MCP tutorials show you a “hello world” tool β€” one function, two arguments, a print statement β€” and stop right where the actual decisions start. A server with one tool doesn’t need a design; it needs a decorator. The real work begins once you have more than one thing to expose and have to decide what a “tool” is versus what should really be a “resource,” whether an LLM client should be trusted to call anything in your server unauthenticated, and how much of your internal API surface you’re comfortable handing to a model that decides for itself what to call and when.

This post builds an MCP server from scratch in Python, covering all three primitives the protocol defines β€” tools, resources, and prompts β€” and treats the design decisions as seriously as the code. If you’re building this server to sit behind an agent built with LangGraph, the point where an agent’s control flow gets complex enough to need a graph in the first place is its own decision worth understanding before you start deciding how much of your API to expose through it.

You’ll learn:

  • The difference between a tool, a resource, and a prompt in MCP, and when to use each
  • How to build all three using the Python SDK’s FastMCP interface
  • How to structure tool inputs and outputs so an LLM can actually use them reliably
  • The tool-surface decision: what to expose, what to keep internal, and why “expose everything” is a mistake
  • A practical authentication setup for a server that isn’t just running on localhost
  • How to test your server locally with the MCP Inspector before wiring up a real client

Table of Contents

  1. The Three Primitives
  2. Setting Up the Python SDK
  3. Building Tools
  4. Building Resources
  5. Building Prompts
  6. Handling Errors the Way a Model Can Actually Use
  7. Putting It All Together
  8. Versioning Tools Without Breaking Clients
  9. The Tool-Surface Decision
  10. Authentication Before You Ship It Anywhere
  11. Testing Locally with the MCP Inspector
  12. Wrapping Up

The Three Primitives

MCP defines three distinct ways a server can offer capabilities to a client, and conflating them is the single most common design mistake in a first server.

Tools are actions β€” functions the model can decide to call, with typed inputs and a return value. A tool has side effects or does work: sending an email, querying a database, running a calculation. The model chooses when to call a tool and with what arguments, based on its description.

Resources are data the client can read, identified by a URI, that the model doesn’t have to “call” so much as “look at.” Think of a resource as more like a GET endpoint than a function call β€” it’s for content that should be available as context, not something the model needs to reason about invoking with specific parameters.

Prompts are reusable, parameterized message templates the client can surface to a user β€” think a slash-command menu, not something the model calls autonomously. A prompt like /summarize-ticket takes a ticket ID and expands into a full prompt template; the user (or client UI) selects it, the model doesn’t decide to invoke it on its own.

Getting this split right matters because it changes who’s in control. A tool is model-initiated. A resource is typically client-initiated (the client decides what context to attach). A prompt is user-initiated. Building a “resource” that actually needs arguments and has side effects is a sign it should have been a tool.

Setting Up the Python SDK

The official Python SDK ships a FastMCP class that handles the JSON-RPC plumbing, schema generation from type hints, and transport setup, so a minimal server is a handful of lines.

pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Support Tools")

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

stdio transport is the right default for local development and for servers a client launches as a subprocess β€” it’s what an editor integration or a local agent typically uses. For a server that needs to be reachable over the network, mcp.run(transport="streamable-http") serves the same server over HTTP instead, without changing any of the tool, resource, or prompt definitions below.

Building Tools

A tool is just a Python function with a @mcp.tool() decorator. The SDK reads your type hints and docstring to generate the JSON schema the client sees β€” this is also the model’s only information about when and how to call it, so treat the docstring as the API documentation it actually is.

from pydantic import BaseModel, Field

class RefundResult(BaseModel):
    refund_id: str
    status: str
    amount: float

@mcp.tool()
def issue_refund(
    order_id: str = Field(description="The order ID to refund, e.g. 'ord_8213'"),
    amount: float = Field(description="Refund amount in USD, must be <= the order total"),
    reason: str = Field(description="Short reason for the refund, shown to the customer"),
) -> RefundResult:
    """Issue a refund for a completed order. Only works on orders older than 24 hours."""
    order = get_order(order_id)
    if order.total < amount:
        raise ValueError(f"Refund amount {amount} exceeds order total {order.total}")
    refund = process_refund(order_id, amount, reason)
    return RefundResult(refund_id=refund.id, status=refund.status, amount=amount)

A few things worth being deliberate about:

  • Raise real exceptions for invalid input. The SDK converts an uncaught exception into an error result the model sees, which is exactly what you want β€” a model that gets a clear “refund amount exceeds order total” error can often self-correct on the next call, whereas a tool that silently does the wrong thing can’t be caught at all.
  • Return structured data, not a formatted string. Returning a Pydantic model (or a dict) gives the client β€” and any code downstream of the client β€” something it can parse reliably, instead of a model having to re-parse a sentence you wrote for humans.
  • Keep each tool doing one thing. A tool named manage_order that takes an action: str parameter (“refund”, “cancel”, “resend”) pushes a decision the protocol is built to handle β€” which tool to call β€” into a string the model has to get exactly right. Separate tools with separate, specific names are easier for a model to select correctly and easier for you to individually gate behind auth or a confirmation step.

Async tools work the same way β€” just declare the function async def and the SDK handles it:

@mcp.tool()
async def lookup_shipment_status(tracking_number: str) -> str:
    """Look up the current status of a shipment by tracking number."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://carrier.example.com/track/{tracking_number}")
        resp.raise_for_status()
        return resp.json()["status"]

Building Resources

Resources are registered with a URI template rather than being invoked with arbitrary arguments. A client reads a resource by its URI; the server resolves it to content.

@mcp.resource("orders://{order_id}/summary")
def order_summary(order_id: str) -> str:
    """A human-readable summary of an order, for use as context."""
    order = get_order(order_id)
    return (
        f"Order {order.id}: {len(order.items)} items, "
        f"total ${order.total:.2f}, status: {order.status}"
    )

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

The URI scheme (orders://, config://) is entirely your own convention β€” MCP doesn’t standardize what schemes mean, it just requires resources to be addressable by URI. Use static resources like config://support-policies for content that should always be available as background context regardless of what the conversation is about, and templated resources like orders://{order_id}/summary for content the client fetches based on what’s actually being discussed.

Building Prompts

A prompt returns one or more messages, optionally parameterized, meant to be selected by a user rather than invoked by the model mid-reasoning.

from mcp.server.fastmcp.prompts import base

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [
        base.UserMessage(
            f"Write a {tone} message to the customer explaining the refund decision "
            f"for order {order.id}. Order details: {order.items}, total ${order.total:.2f}."
        )
    ]

A client with a UI β€” a chat client, an internal support tool β€” can surface this as a selectable action (“Draft refund response”) that expands into a fully-formed prompt with the order’s real data already filled in, rather than requiring the user to type all of that context by hand every time.

Handling Errors the Way a Model Can Actually Use

It’s worth separating two kinds of failure a tool can hit, because the SDK treats them differently and your code should too. An uncaught exception becomes a tool-call error the client sees as a failure β€” appropriate for genuine bugs or unrecoverable states. A validation failure the model could plausibly fix β€” a malformed order ID, an amount outside an allowed range β€” is better returned as a normal, structured result that describes what went wrong, because that gives the model a clean fact to reason from instead of an opaque error object.

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund for a completed order. Returns success=False with an
    explanation if the refund can't be processed, rather than raising."""
    order = get_order(order_id)
    if order is None:
        return RefundOutcome(success=False, error=f"No order found with ID {order_id}")
    if amount > order.total:
        return RefundOutcome(
            success=False,
            error=f"Refund amount {amount} exceeds order total {order.total:.2f}",
        )
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

This pattern β€” a result type with a success flag rather than an exception β€” tends to produce better agent behavior in practice, because the model sees the failure as part of the normal conversation rather than as a break in the interaction. Reserve real exceptions for cases where continuing genuinely doesn’t make sense: a database connection that’s down, an internal invariant that’s been violated, something no amount of the model retrying with different arguments would fix.

Putting It All Together

A single file combining the pieces above β€” a read tool, a write tool, a resource, and a prompt β€” is a realistic shape for a first real server, small enough to hold in your head and complete enough to actually be useful behind an agent:

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
from pydantic import BaseModel

mcp = FastMCP("Support Tools")

class RefundOutcome(BaseModel):
    success: bool
    refund_id: str | None = None
    error: str | None = None

@mcp.tool()
def lookup_order(order_id: str) -> dict:
    """Look up an order by ID. Read-only, safe to call freely."""
    order = get_order(order_id)
    return order.model_dump() if order else {"error": "not found"}

@mcp.tool()
def issue_refund(order_id: str, amount: float, reason: str) -> RefundOutcome:
    """Issue a refund. Has a real side effect β€” use only after confirming with the user."""
    order = get_order(order_id)
    if order is None or amount > order.total:
        return RefundOutcome(success=False, error="Invalid order or amount")
    refund = process_refund(order_id, amount, reason)
    return RefundOutcome(success=True, refund_id=refund.id)

@mcp.resource("config://support-policies")
def support_policies() -> str:
    """Static policy text every support conversation should have as context."""
    return SUPPORT_POLICY_TEXT

@mcp.prompt()
def draft_refund_response(order_id: str, tone: str = "empathetic") -> list[base.Message]:
    """Draft a customer-facing message explaining a refund decision."""
    order = get_order(order_id)
    return [base.UserMessage(f"Write a {tone} message about the refund for order {order.id}.")]

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

Notice the shape: one read tool with no meaningful downside to calling it, one write tool that returns a structured outcome instead of raising, one resource for always-relevant static context, and one prompt for a user-triggered action. That’s a template worth keeping as you add more β€” before adding tool number nine, ask which of these four categories it actually belongs to.

Versioning Tools Without Breaking Clients

Once a server has real clients depending on it, changing a tool’s schema is a breaking change in the same sense as changing a REST endpoint’s response shape β€” a client (or the model, mid-conversation) built against the old schema doesn’t automatically know about the new one. A few practices that keep this manageable:

  • Add optional parameters with defaults, don’t change existing required ones. Adding an optional currency: str = "USD" to issue_refund is safe. Renaming amount to refund_amount breaks every existing call site and every model that’s already seen the old description in its context.
  • Version the tool name for genuinely incompatible changes. If a tool’s behavior needs to change in a way that isn’t backward compatible, ship issue_refund_v2 alongside the original rather than mutating issue_refund underneath existing callers, and deprecate the old one on your own timeline.
  • Keep descriptions in sync with actual behavior. A stale docstring is worse than a missing one β€” it actively misleads the model about what a tool does or what constraints apply, and that failure mode is silent until someone notices the tool being called wrong.

The Tool-Surface Decision

This is the part that doesn’t show up in a “hello world” tutorial: once your server has real tools with real side effects, what you choose to expose is a bigger decision than how you implement any individual tool.

Default to read before write. A lookup_order tool is low-risk β€” worst case, a confused model looks up the wrong order. An issue_refund tool is not β€” a model that misreads context and calls it with the wrong amount has caused a real, external effect. Expose read tools generously; expose write tools deliberately, and consider gating anything irreversible behind a confirmation step or a human-in-the-loop pattern in the calling agent rather than trusting the model’s judgment alone.

Don’t expose your entire internal API just because you can. It’s tempting to wrap every existing internal function as a tool once you have the decorator pattern down. Resist it. Every tool you expose is something the model can choose to call in a context you didn’t anticipate β€” the more tools in a server, the more the model’s tool-selection accuracy degrades, and the larger your effective attack surface if the client (or the model) is ever compromised or manipulated by injected content. A server with eight well-described, narrow tools is more reliable and more auditable than one with forty.

Think about who’s actually calling this. A server meant to sit behind a single internal agent your team controls can reasonably expose more than a server meant to be handed to a third-party client you don’t control. Write down, before you add a tool, who you expect to be calling it and what they’re allowed to do β€” that answer should inform both the tool surface and the auth model below.

Validate aggressively inside the tool, not just at the schema level. JSON schema validation catches “the model passed a string where an int was expected.” It does not catch “the model passed a valid order ID that belongs to a different customer than the one in this conversation.” That’s a business-logic check your tool function has to make explicitly β€” the protocol doesn’t and can’t know about your data model.

Authentication Before You Ship It Anywhere

A server running over stdio on someone’s own laptop has no real auth concern β€” it inherits whatever permissions the local process has. The moment you serve over streamable-http and it’s reachable from anywhere other than localhost, it needs the same authentication discipline as any other API.

The minimum viable version is a bearer token checked before a request reaches your tool logic:

from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

VALID_TOKEN = os.environ["MCP_SERVER_TOKEN"]

class BearerAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        auth = request.headers.get("authorization", "")
        if auth != f"Bearer {VALID_TOKEN}":
            return JSONResponse({"error": "unauthorized"}, status_code=401)
        return await call_next(request)

app = mcp.streamable_http_app()
app.add_middleware(BearerAuthMiddleware)

This clears the bar of “not wide open,” but it’s still a single shared secret with no per-caller identity and no scoping β€” anyone with the token can call every tool the server exposes. For anything beyond an internal, trusted client, plan to move toward OAuth-based authorization so tokens are bound to a specific identity and can be scoped to a subset of tools rather than all-or-nothing access. Whatever you choose, keep the auth check outside your tool functions β€” at the transport or middleware layer β€” so a bug in one tool can never accidentally skip the check for another.

Testing Locally with the MCP Inspector

Before wiring your server up to a real agent, the MCP Inspector gives you a UI to call tools, read resources, and expand prompts directly, without writing a client at all.

mcp dev server.py

This launches your server and opens the Inspector in a browser, where you can invoke issue_refund with test arguments and see exactly what the client would see β€” the schema the model gets, the structured result, and any error path you trigger by passing bad input. It’s worth deliberately testing the error paths here: call issue_refund with an amount larger than the order total and confirm the error message is something a model could plausibly use to self-correct, not a raw Python traceback.

Wrapping Up

The mechanics of an MCP server β€” decorators, type hints, a docstring β€” take a few minutes to learn and are genuinely the easy part. The decisions that determine whether the server is actually good are the ones a tutorial can’t hand you: which of your three primitives a given capability really is, how narrow to cut each tool, what stays internal, and what authentication a server reachable off localhost actually needs before anyone else connects to it. Get the primitives right and the tool surface deliberate, and the rest β€” auth, testing, deployment β€” is standard backend engineering wearing an MCP-shaped interface.

Related: See how an MCP server supports software investigation in Reverse Engineer Anything (REA): setup and use cases.

Faizan Nadeem
Author
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

More articles