Build an MCP Server
Build a working Model Context Protocol server from scratch and call it from a client — the standard way to give AI agents governed access to your tools and data.
Download starter repoOn this page▾
- What you'll build
- Why this matters (the 5-minute business case)
- Concepts you'll use (2-minute primer)
- Prerequisites
- Setup (3 minutes)
- Exercise 1 — Your first MCP server (12 min)
- Exercise 2 — Tools, resources, and prompts (12 min)
- Exercise 3 — A real server: validation, structured output, and Context (12 min)
- Exercise 4 — Connect it to a real host (10 min)
- Wrap-up
- Stretch challenges (for the labs series)
- Troubleshooting
- References
What you'll build
By the end of this lab you'll have built a working MCP server with the official Python SDK — and driven it with your own client. The Model Context Protocol (MCP) is the open standard for connecting AI applications to your tools and data: "like a web API, but designed for LLM interactions." Across the earlier labs you kept bumping into MCP without building one — Lab 02 discovered servers of type application/mcp-server+json, and Lab 01's Interactions API could call a {"type": "mcp_server"} tool. This lab is where you make the thing those specs point at.
You'll build an Acme Orders server that exposes:
- Tools —
get_order,search_orders,create_ticket(actions and computation) - A resource —
orders://schema(data loaded into a model's context) - A prompt —
triage_order(a reusable interaction template)
Then you'll connect to it two ways: with a small Python client you write, and with the official MCP Inspector / Claude Code.
Everything is local. You don't need an LLM to complete the lab — the client drives the server directly so you can see the protocol clearly.
Why this matters (the 5-minute business case)
Read this section before handing the lab to your team.
MCP solves the "M×N integrations" problem that quietly dominates the cost of enterprise AI. Before a standard, every AI application (Claude, an internal agent, an IDE assistant, a chatbot) needed a custom integration to every system it touched (your CRM, your data warehouse, your ticketing tool, your wiki). That's M applications times N systems of bespoke, separately-maintained glue. MCP turns it into M+N: each system exposes one MCP server, and each application speaks one protocol. The integration you build once is reusable by every current and future AI client that speaks MCP.
That reframing has concrete business consequences:
- Build once, reuse everywhere. The server you wrap around your orders system today works with Claude, with your own agents, with a coding assistant, and with whatever AI tool your team adopts next year — no rework. For a platform team, that's the difference between maintaining a dozen one-off connectors and maintaining one server.
- A clean security and governance boundary. An MCP server is a deliberate, auditable surface: these tools, these resources, nothing else. The model can't reach into your database directly — it can only call the functions you chose to expose, which validate their inputs and run your code. That makes "what can the AI actually do to our systems?" a question with a concrete, reviewable answer. (It also pairs with Lab 02's lesson: discovery finds servers, but you still verify and govern what you connect to.)
- Separation of concerns that matches how orgs are structured. The team that owns a system can own its MCP server and the tools it exposes; the teams building agents consume it without needing to understand its internals. The protocol is the contract between them — the same pattern that made REST APIs organizationally scalable.
The honest caveats, because pragmatic adoption means naming them:
- A tool surface is an attack surface. Exposing actions to an AI means an AI (possibly steered by a malicious prompt) can invoke them. Every tool needs input validation, least-privilege access, and care around side effects — especially anything that writes data, spends money, or sends messages. "It's just an MCP tool" is not a security model. (This is exactly why a future lab in this series covers prompt injection and agent security.)
- Standard ≠ trivial. MCP standardizes the wire protocol, not your judgment about what to expose or how to authenticate it. Authentication, authorization, rate limiting, and auditing are still your job — the SDK gives you the hooks, not the policy.
- It's young and moving. MCP is evolving quickly (transports, auth, and capabilities have all changed over its first year). Pin your SDK version and expect to track updates.
A useful framing for a non-technical stakeholder: "MCP is a universal adapter between AI tools and our systems. Instead of building a custom connection for every AI product we use, we expose each system once and every AI client can use it — with a clear, reviewable boundary around exactly what the AI is allowed to do."
Concepts you'll use (2-minute primer)
MCP has three building blocks, and the SDK maps each to a Python decorator:
- Tools (
@mcp.tool()) — things the model can do. Like POST endpoints: they run code and may have side effects. The model decides when to call them; your code executes them. - Resources (
@mcp.resource(uri)) — data the model can read. Like GET endpoints: they load context (a schema, a document, settings) and shouldn't have side effects. - Prompts (
@mcp.prompt()) — reusable templates that package a good way to ask the model to do something with your server.
Two roles, two transports:
- A server exposes capabilities; a client/host (an agent or app) consumes them.
- stdio transport: the host launches the server as a subprocess and talks over stdin/stdout (great for local tools). Streamable HTTP transport: the server listens on a URL (great for shared, networked servers).
Prerequisites
| You need | Notes |
|---|---|
| Python 3.10+ | The MCP SDK targets modern Python |
pip + network | To install the MCP SDK (mcp[cli]) |
| Node (optional) | Only for the official Inspector (npx) in Exercise 4 |
| ~$0, no LLM required | The client drives the server directly |
Get the starter repo. This lab refers to files in the accompanyingmcp-lab/repo:orders.py(logic),server.py,client.py,test_orders.py. Unzip it andcdin.
Setup (3 minutes)
cd mcp-lab
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt # installs mcp[cli]Sanity-check the business logic before touching the protocol — these tests need no SDK:
python test_orders.py ok test_get_order_ok
...
8/8 tests passedWhy logic and protocol are separate files.orders.pyholds the data and functions with no MCP import;server.pyis a thin wrapper that exposes them over MCP. This keeps your tools unit-testable and reusable, and it's the single most useful habit for building maintainable servers. You just ran the proof: full test coverage of the logic with the protocol layer absent.
Exercise 1 — Your first MCP server (12 min)
Goal: Stand up a server with one tool and call it from a client — seeing the full MCP handshake.
1a. Read the smallest possible server
Open server.py. Strip it down in your head to just this shape:
from mcp.server.fastmcp import FastMCP
import orders
mcp = FastMCP("Acme Orders")
@mcp.tool()
def get_order(order_id: str) -> Order:
"""Look up a single order by its ID (e.g. 'A-1024')."""
return Order(**orders.get_order(order_id))
if __name__ == "__main__":
mcp.run() # stdio transport by defaultThree things make this a complete MCP tool:
- `@mcp.tool()` registers the function as a callable tool.
- The type hints (
order_id: str,-> Order) become the tool's input and output schema automatically — the SDK generates it from your signature. This is why theOrderPydantic model matters: it gives the tool structured output the client can rely on. - The docstring becomes the tool's description — the text a model reads to decide whether to call it. Treat it like UX copy.
1b. Drive it with a client
client.py is the "host" side — the role an agent plays. Run it:
python client.pyIt launches server.py as a subprocess over stdio and walks the handshake:
Tools: ['get_order', 'search_orders', 'create_ticket']
Resources: ['orders://schema']
Prompts: ['triage_order']
...
--- call tool: get_order(A-1024) ---
structured: {'order_id': 'A-1024', 'customer': 'Wile E. Coyote', 'status': 'shipped', ...}Look at the sequence in client.py: stdio_client(...) starts the server, ClientSession(...) wraps the connection, await session.initialize() performs the MCP handshake, and then list_tools() / call_tool() do the work. That initialize step is where client and server negotiate capabilities — it's the protocol's "hello."
Business callout: notice the client never imported orders.py or knew anything about how orders are stored. It discovered the tools at runtime and called them over a standard protocol. That's the whole value proposition — the consumer and the system are decoupled by MCP, so either can change without breaking the other.Checkpoint: You have a running MCP server and a client that discovers and calls it. Now let's use all three capability types.
Exercise 2 — Tools, resources, and prompts (12 min)
Goal: Understand the three building blocks by using each, and learn when to reach for which.
2a. A resource (data for context)
In server.py:
@mcp.resource("orders://schema")
def schema() -> str:
"""The orders dataset schema, for loading into the model's context."""
return orders.orders_schema()A resource is addressed by a URI (here orders://schema) and returns data, not actions. The client reads it with read_resource:
res = await session.read_resource(AnyUrl("orders://schema"))Use a resource when you want to load context — a schema, a config, a document, a data dictionary — so the model knows the shape of your world before it acts. Resources can be templated too (e.g. order://{id}); see the stretch challenge.
2b. A prompt (a reusable template)
@mcp.prompt(title="Triage an order")
def triage_order(order_id: str) -> str:
return (f"Look up order {order_id} using the get_order tool, summarize its "
f"status in one sentence, and recommend whether a ticket is warranted.")A prompt packages a good way to ask. Hosts surface prompts to users (e.g. as slash-commands), so your server can ship expert workflows, not just raw capabilities. The client fetches one with get_prompt("triage_order", {"order_id": "A-1024"}).
2c. The mental model
| Building block | Decorator | Analogy | Use it to… |
|---|---|---|---|
| Tool | @mcp.tool() | POST | do something / compute |
| Resource | @mcp.resource(uri) | GET | load context / data |
| Prompt | @mcp.prompt() | a saved query | package a good interaction |
Run python client.py again and watch all three show up in the discovery output.
Business callout: this triad is why MCP is more than "remote function calling." A well-built server doesn't just expose actions — it ships the context a model needs to use them well (resources) and the expertise of how to use them (prompts). That's the difference between handing someone a power tool and handing them the tool plus the manual plus a jig.
Checkpoint: You can expose all three MCP capability types and explain when each fits.
Exercise 3 — A real server: validation, structured output, and Context (12 min)
Goal: Move from toy to credible — typed outputs, input validation, side effects done safely, and server-side logging.
3a. Structured output with Pydantic
Look at the Order model and search_orders in server.py:
class Order(BaseModel):
order_id: str = Field(description="Unique order identifier")
status: str = Field(description="processing | shipped | delivered | cancelled")
total_usd: float = Field(description="Order total in US dollars")
...
@mcp.tool()
def search_orders(status: str | None = None, min_total_usd: float = 0.0) -> list[Order]:
"""Find orders by status and/or a minimum total in USD."""
return [Order(**o) for o in orders.search_orders(status, min_total_usd)]Returning a Pydantic model (or a list of them) gives the tool a real output schema, so the client gets structuredContent it can parse reliably instead of guessing at free text. Run the client and compare the structured: lines — that's machine-usable data, not prose.
3b. Validation and safe side effects
create_ticket is an action — it writes data. Open orders.py and notice it validates before acting: it confirms the order exists and the issue is non-empty, raising ValueError otherwise. The server tool adds logging via Context:
@mcp.tool()
async def create_ticket(order_id: str, issue: str, ctx: Context[ServerSession, None]) -> dict:
"""Open a support ticket against an order. This is a side-effecting action."""
await ctx.info(f"Opening ticket for {order_id}")
ticket = orders.create_ticket(order_id, issue) # raises on bad input
await ctx.info(f"Created {ticket['ticket_id']}")
return ticketThe Context parameter is injected by the SDK when you type-hint it. It gives a tool access to MCP capabilities — logging (ctx.info/warning/error), progress reporting (ctx.report_progress), reading other resources (ctx.read_resource), and more — without you wiring any of it up.
3c. Errors are part of the contract
Run the client and look at the last block:
--- error handling: get_order(NOPE) ---
isError: True
message: ... Order 'NOPE' not found.When your tool raises, the SDK turns it into a proper MCP error result (isError: True) rather than crashing the server. The host sees a clean, structured failure it can relay to the model or the user. Good tools fail loudly and specifically — vague errors are how agents get stuck in loops.
Business callout: this is the slide for your security and reliability review. A production MCP tool is not "expose the database to the AI." It's: typed inputs and outputs, validation before any side effect, least-privilege scope (only the tools you chose), and structured errors. The server is the governed boundary between the model's intentions and your real systems — design it like one.
Checkpoint: Your server has typed outputs, validated actions, server-side logging, and clean error handling — the bones of something you'd actually deploy.
Exercise 4 — Connect it to a real host (10 min)
Goal: Use your server from the official tooling and from an AI client, closing the loop with the rest of the series.
4a. The MCP Inspector
The Inspector is a UI for poking at any MCP server. Start your server, then launch the Inspector:
python server.py # stdio
npx -y @modelcontextprotocol/inspectorConnect it to your server and you can list tools, fill in arguments, and call them by hand — invaluable for debugging a server before any model touches it.
4b. Connect to an AI client over HTTP
Switch transports and register the server with an MCP-aware client (Claude Code shown):
python server.py --http # serves http://localhost:8000/mcp
claude mcp add --transport http acme-orders http://localhost:8000/mcpNow an AI client can discover and call get_order, search_orders, and create_ticket on its own — the model decides when, your server decides what's allowed.
4c. Tie it back to the stack
This is the payoff of the whole series. The server you just built is:
- the
application/mcp-server+jsoncapability a Lab 02 (ARD) registry would index and a client would verify before connecting; - a
{"type": "mcp_server"}tool a Lab 01 (Interactions API) call could attach so Gemini can use it; - something whose schema and usage notes you could document as a Lab 03 (OKF) concept so other teams' agents understand it.
Invoke → discover → know → integrate. You've now built every layer.
Business callout: the same server, unchanged, is reachable by the Inspector (debugging), Claude Code (a coding assistant), your own agents, and a discovery registry. That "build once, used by many" reach is the entire economic argument for adopting MCP rather than continuing to write point-to-point integrations.
Checkpoint: Your server runs under real tooling and real AI clients over both transports.
Wrap-up
You built and consumed a real MCP server. You can now:
- Expose tools, resources, and prompts with FastMCP and explain when each fits
- Give tools structured output (Pydantic), input validation, and safe side effects
- Use Context for server-side logging and capabilities, and return clean errors
- Connect a server to a client, the Inspector, and an AI host over stdio and HTTP
The one idea to carry forward: an MCP server is a governed, reusable boundary between AI clients and your systems. It converts the M×N integration mess into M+N, and it makes "what can the AI do to our systems?" a concrete, reviewable answer — the set of tools you chose to expose, each validating its own inputs. Build the boundary well and every AI client you adopt inherits it for free.
Stretch challenges (for the labs series)
- Templated resources. Add
@mcp.resource("order://{order_id}")that returns a single order as a readable document, so a host can load one order into context by URI. Compare the ergonomics with theget_ordertool. - Make the model actually use it. Wire this server into the Lab 01 Interactions API client: attach it as a
{"type": "mcp_server"}tool and ask Gemini "is order A-1099 on track?" Watch discovery → tool call → answer happen end-to-end. - Publish + discover it. Write an
ai-catalog.jsonentry of typeapplication/mcp-server+jsonfor this server (Lab 02), sign it, and have the ARD client verify it before connecting. That stitches Labs 02 and 04 together. - Add auth. Put a bearer token on the HTTP transport and reject unauthenticated calls — the first step toward a server you'd expose beyond localhost.
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
ModuleNotFoundError: mcp | pip install -r requirements.txt (installs mcp[cli]). The orders.py/test_orders.py logic runs without it. |
client.py hangs or can't start the server | It runs server.py from the current directory — run client.py from inside mcp-lab/. |
| Inspector can't connect | Make sure python server.py is still running; match the transport (stdio vs the --http URL). |
claude mcp add fails | Use the --http server and the exact URL http://localhost:8000/mcp. |
| A tool returns text, not structured data | Give it a typed return (a Pydantic model, TypedDict, or annotated primitive) so the SDK can generate an output schema. |
| Port 8000 in use | Set a different port when running the HTTP transport, and update the client URL. |
References
Technical details follow the official MCP specification and Python SDK:
- Model Context Protocol — overview & spec
- MCP Python SDK (FastMCP, client, transports)
- MCP Inspector
- Connecting MCP servers to Claude Code
A note on accuracy for your readers: MCP is evolving quickly. This lab's code follows the official Python SDK examples (mcp.server.fastmcp.FastMCP,mcp.client.stdio). The pure-logic layer (orders.py) is unit-tested here; the SDK wiring is syntax-verified and mirrors the SDK's own snippets, but pin yourmcpversion and re-run against it before publishing, noting the version you validated.
Written by Wes Goldwater, Director of Engineering at Prosigliere.
Hands-on cloud & pragmatic AI from Goldwater.dev.