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
mcpSDK): 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/sdkwithMcpServerandserver.registerTool(...). - Pick a transport:
stdiofor local subprocess servers (the default, zero network exposure) orstreamable-httpfor 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: strbecomes{"type": "string"}. - Docstring → becomes the tool
descriptionthe 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_issuebeat onemanage_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
inputSchemaconstrains shape, but you still validate ranges, authorization, and side effects server-side. Neverevalor 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: truewith 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.
Summary + related articles
- 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):
- MCP Fundamentals — architecture, primitives, transports, the M×N problem.
- MCP Clients — how hosts discover and call the servers you build.
- MCP in the Real World — the official server catalog and production patterns.
- MCP Security Considerations — tool poisoning, injection, and hardening your server.
Sources: Build an MCP server · python-sdk (FastMCP) · fastmcp on PyPI · Security best practices · DataCamp MCP interview Qs