← Back to Learning Hub

Building MCP Servers

The 2025 standard for agentsIntermediate12 min

By: Anacodic Team

TL;DR

  • An MCP server is a small program that exposes three primitives to any MCP host: Tools (model-callable functions), Resources (readable data/context), and Prompts (reusable templated workflows).
  • The fastest path in Python is FastMCP (bundled in the official mcp SDK): decorate a plain function with @mcp.tool() and the SDK auto-generates the JSON Schema from your type hints and docstring.
  • The TypeScript equivalent is @modelcontextprotocol/sdk with McpServer and server.registerTool(...).
  • Pick a transport: stdio for local subprocess servers (the default, zero network exposure) or streamable-http for remote/hosted servers.
  • Test before wiring into an LLM using the MCP Inspector (npx @modelcontextprotocol/inspector) — a browser UI that lists and invokes your tools/resources/prompts.
  • Golden rules: narrow, well-described tools; validate inputs; never trust arguments; return structured content; keep tool descriptions honest (they're read by the model and are a real attack surface).

Simple explanation + analogy

A server is a catalog of capabilities you hand to an AI app. Each tool is a service the model can book; the description and parameter schema are the catalog listing and the "choose your options" boxes. Your job when building a server is to write a clear catalog: unambiguous names, honest descriptions, and precise parameter types — because the model books purely from what the catalog says.

If the listing is vague ("process: does stuff"), the model books the wrong service or fills in nonsense arguments. If it's precise ("create_issue(repo: str, title: str): opens a GitHub issue"), the model books correctly. Building an MCP server is 20% code and 80% writing a catalog an LLM can read.

In a trip-planning assistant, each Strands @tool function (search_flights, get_activities) already reads like a catalog listing with a docstring and typed args. Converting those to MCP is largely mechanical: swap the decorator, keep the catalog.


Diagram

   your_server.py
   ┌──────────────────────────────────────────────────────────┐
   │  mcp = FastMCP("weather")                                 │
   │                                                           │
   │  @mcp.tool()      ──►  registered as a TOOL               │
   │  def get_forecast(city: str) -> str: ...                  │
   │        │  type hints ─► auto JSON Schema (inputSchema)     │
   │        │  docstring  ─► tool description                   │
   │                                                           │
   │  @mcp.resource("config://app")  ──►  registered RESOURCE  │
   │  def get_config() -> str: ...                             │
   │                                                           │
   │  @mcp.prompt()    ──►  registered PROMPT                  │
   │  def review(code: str) -> str: ...                        │
   │                                                           │
   │  mcp.run(transport="stdio")   # or "streamable-http"      │
   └──────────────────────────────┬───────────────────────────┘
                                   │ JSON-RPC 2.0
                     ┌─────────────┴─────────────┐
                     ▼                           ▼
             MCP Inspector (test)          Host / Agent (prod)
             npx ...inspector              Claude Desktop, Cursor,
                                           LangGraph, Strands...

How it works (deep)

Step 1 — Choose an SDK and scaffold

The official SDKs handle the JSON-RPC framing, the initialize handshake, capability advertisement, and schema generation. You almost never touch raw JSON-RPC. Python's mcp package ships FastMCP, a decorator-based high-level API; TypeScript ships @modelcontextprotocol/sdk. There are also SDKs for Java/Kotlin, C#, Go, Rust, and Swift.

Step 2 — Define tools (the model-controlled primitive)

A tool is a function the LLM may call. In FastMCP you decorate a function; the SDK inspects:

  • Type hints → generates the inputSchema (a JSON Schema object). city: str becomes {"type": "string"}.
  • Docstring → becomes the tool description the model reads to decide whether and how to call it.
  • Return type → serialized into the tool result content.

This is why type hints and docstrings are load-bearing, not cosmetic. They are the contract the model sees.

Step 3 — Define resources (the app-controlled primitive)

Resources expose read-only data identified by a URI (config://app, file:///logs/app.log, db://schema/users). The host decides when to read them into context (e.g., attach a file). Resources can be static or templated (user://{id}/profile) so the URI carries parameters. Resources are for context you load, not actions the model takes — that distinction is the difference between a resource and a tool.

Step 4 — Define prompts (the user-controlled primitive)

Prompts are reusable, parameterized message templates surfaced to the user (often as slash commands). @mcp.prompt() on a function that returns a string (or a list of messages) exposes something like /review-code that pre-fills a high-quality prompt. Prompts encode your best-practice workflows so users don't have to reinvent them.

Step 5 — Pick a transport and run

  • mcp.run(transport="stdio") → server runs as a subprocess; the host launches it and talks over stdin/stdout. Best for local tools.
  • mcp.run(transport="streamable-http") → server listens on an HTTP endpoint; best for remote/shared/hosted servers. Streamable HTTP superseded the older HTTP+SSE transport and can run statelessly for horizontal scaling.

Step 6 — Test with the MCP Inspector

Before you ever attach the server to an LLM, run the Inspector:

npx @modelcontextprotocol/inspector uv run server.py

It opens a browser UI where you can view the negotiated capabilities, list tools/resources/prompts, and invoke tools with hand-entered arguments to confirm behavior deterministically. Debugging a tool through a non-deterministic LLM is painful; the Inspector removes the model from the loop.

Design principles that separate good servers from bad

  • Narrow tools beat broad tools. create_issue + close_issue beat one manage_issue(action=...) — the model chooses correctly more often.
  • Descriptions are honest and specific. The model acts on the description verbatim; vague or misleading descriptions cause misuse (and malicious ones are the basis of tool poisoning — see the security article).
  • Validate every argument. The inputSchema constrains shape, but you still validate ranges, authorization, and side effects server-side. Never eval or shell-interpolate raw arguments.
  • Return structured, minimal content. Don't dump 50KB of JSON; return what the model needs.
  • Fail loudly and safely. Set isError: true with a clear message rather than throwing opaque stack traces into the model's context.

Real code

Python (FastMCP) — a faithful minimal server with all three primitives

# server.py  —  run:  uv run server.py   (or python server.py)
from mcp.server.fastmcp import FastMCP

# The FastMCP object owns your tools, resources, and prompts.
mcp = FastMCP("Demo")


# ---- TOOL: model-controlled action. Type hints -> JSON Schema; docstring -> description.
@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers and return the sum."""
    return a + b


# ---- RESOURCE: app-controlled read-only context, addressed by a URI template.
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
    """Return a personalized greeting for the given name."""
    return f"Hello, {name}!"


# ---- PROMPT: user-controlled reusable template (e.g. surfaced as a slash command).
@mcp.prompt()
def review_code(code: str) -> str:
    """Produce a code-review prompt for the supplied code."""
    return f"Please review this code and list bugs and improvements:\n\n{code}"


if __name__ == "__main__":
    # stdio for local use; switch to "streamable-http" to serve remotely.
    mcp.run(transport="stdio")

This mirrors the official fastmcp_quickstart.py in the modelcontextprotocol/python-sdk repo. Notice you wrote zero JSON-RPC and zero schema — the SDK derives inputSchema from a: int, b: int and the description from the docstring.

A realistic tool that calls an external API

import json
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("flights")

@mcp.tool()
async def search_flights(origin: str, destination: str, date: str) -> str:
    """Search flights via the flights API.

    Args:
        origin: departure city/airport, e.g. "Boston, BOS".
        destination: arrival city/airport, e.g. "Lisbon, LIS".
        date: departure date (YYYY-MM-DD).

    Returns:
        JSON string of matching flights (airline, price, departure time).
    """
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(
            "https://api.flights.example.com/v1/search",
            params={"origin": origin, "destination": destination, "date": date, "limit": 5},
            headers={"Authorization": f"Bearer {_FLIGHTS_KEY}"},
        )
    resp.raise_for_status()
    hits = resp.json().get("flights", [])
    return json.dumps([
        {"airline": f["airline"], "price": f["price"], "departs": f["departure_time"]}
        for f in hits
    ])

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

Compare to a Strands @tool version of search_flights: the body is nearly identical — the MCP version just makes it consumable by any host, not only one app's own agents.

TypeScript — the equivalent minimal server

// server.ts  —  npm i @modelcontextprotocol/sdk zod
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "demo", version: "1.0.0" });

// Tool: schema declared with zod; SDK converts it to JSON Schema.
server.registerTool(
  "add",
  {
    title: "Add",
    description: "Add two numbers and return the sum.",
    inputSchema: { a: z.number(), b: z.number() },
  },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }],
  }),
);

// Resource with a URI template.
server.registerResource(
  "greeting",
  "greeting://{name}",
  { title: "Greeting" },
  async (uri, { name }) => ({
    contents: [{ uri: uri.href, text: `Hello, ${name}!` }],
  }),
);

const transport = new StdioServerTransport();
await server.connect(transport);

Test it (no LLM required)

# Python
npx @modelcontextprotocol/inspector uv run server.py
# TypeScript
npx @modelcontextprotocol/inspector node build/server.js

Open the printed localhost URL → connect → Tools tab → run add with {a: 2, b: 3} → confirm 5. Then wire the server into Claude Desktop / Cursor / your agent.


Real-world example

Wiring the server into Claude Desktop. After the Inspector confirms it works, register it in the host config (claude_desktop_config.json):

{
  "mcpServers": {
    "flights": {
      "command": "uv",
      "args": ["--directory", "/abs/path/to/project", "run", "server.py"]
    }
  }
}

On restart, Claude Desktop spawns your server over stdio, runs the handshake, calls tools/list, and the search_flights tool becomes usable in chat. The same server file also drops into Cursor or a LangGraph agent unchanged — you built it once.

Grounding in a review pipeline. A fixed multi-step review pipeline built on LangGraph (retrieve → grade → synthesize) can have each node wrapped as an MCP tool in a review server. Another clinical app then calls grade_evidence(claim=...) without importing the pipeline — the server is the reusable interface.


Interview questions companies actually ask

1. Walk me through building a minimal MCP server. [easy] Instantiate FastMCP("name"), decorate functions with @mcp.tool() (type hints → schema, docstring → description), optionally add @mcp.resource(uri) and @mcp.prompt(), then mcp.run(transport="stdio"). (Build a server, FastMCP quickstart)

2. How does the SDK produce a tool's input schema? [medium] FastMCP introspects the function's type hints to generate the JSON Schema inputSchema, and uses the docstring as the tool description. In TS you supply a zod schema that's converted to JSON Schema. (python-sdk)

3. Difference between a tool and a resource when building a server? [medium] A tool is a model-invoked action (may have side effects); a resource is app-loaded read-only context addressed by URI. Rule of thumb: does the model do something (tool) or read something (resource)? (modelcontextprotocol.io)

4. Why are tool descriptions security-critical when authoring a server? [hard] The model acts on the description text verbatim, so a misleading or malicious description can steer the model — this is tool poisoning. Keep descriptions honest, and treat third-party server descriptions as untrusted input. (Simon Willison)

5. How do you test an MCP server without an LLM? [easy] The MCP Inspector (npx @modelcontextprotocol/inspector <cmd>): a UI to view negotiated capabilities and invoke tools/resources/prompts with hand-entered args — deterministic, model-free debugging. (Build a server)

6. Local subprocess vs remote HTTP — how do you choose the transport? [medium] stdio for local tools (no network surface, low latency); streamable-http for hosted/shared servers, which can run statelessly and scale horizontally. Streamable HTTP replaced the older HTTP+SSE transport. (Auth0 spec update)

7. How should a server handle errors and bad input? [hard] Validate arguments server-side beyond the schema (ranges, authz, side effects), never interpolate raw args into shell/SQL, and return isError: true with a clear message rather than throwing raw stack traces into the model's context. (Security best practices)

8. Why prefer several narrow tools over one broad "do-everything" tool? [medium] Narrow, single-purpose tools with precise schemas raise the model's selection accuracy and shrink the blast radius of misuse; a giant manage(action=...) tool is ambiguous and dangerous. (DataCamp)


When to use / tradeoffs

Build your own server when: you have proprietary tools/data to expose to multiple hosts, you want runtime discovery, or you're productizing an internal capability. Reuse an existing server when: the integration is common (GitHub, Slack, Postgres, filesystem) — don't rebuild what modelcontextprotocol/servers already ships.

Tradeoffs: FastMCP/TS SDK make authoring trivial, but you own the operational surface — process lifecycle, auth, rate limits, and (critically) the security of every tool description and argument path. A carelessly built server is a direct injection/confused-deputy vector. Favor stdio locally for zero network exposure; only expose streamable-http behind auth.


  • Build servers with FastMCP (Python) or @modelcontextprotocol/sdk (TypeScript): decorate functions → SDK generates schemas → run(transport=...).
  • Expose Tools / Resources / Prompts; write honest, narrow, well-typed definitions because the model reads them verbatim.
  • Test with the MCP Inspector before attaching an LLM; ship over stdio (local) or streamable-http (remote).

Related articles (6-3):

Sources: Build an MCP server · python-sdk (FastMCP) · fastmcp on PyPI · Security best practices · DataCamp MCP interview Qs