Chapter 58 — Hands-on: build an MCP server and connect it
What you'll build
An MCP server exposing two tools over stdio — one read-only, one that writes —
plus a resource. Then you'll connect it to a host and to your own client.
Step 1 — Install the SDK
pip install "mcp[cli]" # Python SDK
# or: npm install @modelcontextprotocol/sdk
Step 2 — Write the server
# order_server.py
from mcp.server.fastmcp import FastMCP
import re, json, pathlib
mcp = FastMCP("orders")
ORDERS = {
"ORD-1001": {"customer": "C-42", "status": "shipped",
"total_eur": 89.90, "items": ["Blue mug", "Notebook"]},
"ORD-1002": {"customer": "C-42", "status": "processing",
"total_eur": 15.00, "items": ["Pen set"]},
"ORD-2001": {"customer": "C-99", "status": "delivered",
"total_eur": 240.00, "items": ["Desk lamp"]},
}
REFUNDS = pathlib.Path("/tmp/refunds.jsonl")
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Look up an order by its ID. Returns status, items and total.
Use this whenever a customer asks about an order."""
if not re.fullmatch(r"ORD-\d{4}", order_id): # VALIDATE
return {"error": "Invalid order ID. Expected format ORD-1234."}
order = ORDERS.get(order_id)
if not order:
return {"error": f"No order found with ID {order_id}."}
return order
@mcp.tool()
def list_customer_orders(customer_id: str) -> dict:
"""List all order IDs belonging to a customer."""
ids = [oid for oid, o in ORDERS.items() if o["customer"] == customer_id]
return {"customer_id": customer_id, "order_ids": ids, "count": len(ids)}
@mcp.tool()
def request_refund(order_id: str, reason: str) -> dict:
"""REQUEST a refund for an order. This does NOT issue the refund --
it creates a request that a human must approve. Use only when the
customer explicitly asks for a refund."""
order = ORDERS.get(order_id)
if not order:
return {"error": "Unknown order."}
if order["total_eur"] > 100: # POLICY IN CODE
return {"status": "escalated",
"message": "Refunds over 100 EUR require a manager."}
with REFUNDS.open("a") as f:
f.write(json.dumps({"order_id": order_id, "reason": reason,
"state": "pending_approval"}) + "\n")
return {"status": "pending_approval",
"message": f"Refund requested for {order_id}. Awaiting approval."}
@mcp.resource("policy://refunds")
def refund_policy() -> str:
"""The company refund policy, for the assistant to read."""
return (
"Refund policy:\n"
"- Refunds accepted within 30 days of delivery.\n"
"- Orders over 100 EUR require manager approval.\n"
"- Digital goods are non-refundable.\n"
)
if __name__ == "__main__":
mcp.run() # stdio transport by default
Three things to notice — they're the lessons of Part 12 and 14 in code:
get_ordervalidates its input before touching data (Chapter 52).request_refunddoes not issue a refund. It creates a pending request. The irreversible action needs a human (Chapter 60).- The refund policy is enforced in Python, not asked of the model. Rules that must hold belong in code (Chapter 6's lesson, all the way back in Part 2).
Step 3 — Test it by hand
python order_server.py
Paste in:
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"manual-test","version":"0.1.0"}}}}
Then:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order","arguments":{"order_id":"ORD-1001"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"manual-test","version":"0.1.0"}}}}
You should see your tool's output come back as JSON-RPC. You have written a working MCP server.
The SDK also ships an inspector, which is far more pleasant:
mcp dev order_server.py # opens a UI to browse and call your tools
Step 4 — Connect it to a host
Most MCP hosts use a JSON config listing servers. The shape is consistent:
{
"mcpServers": {
"orders": {
"command": "python",
"args": ["/absolute/path/to/order_server.py"]
}
}
}
Restart the host, and your three tools appear. Ask it "What's the status of order ORD-1001?" and watch it call your server.
Check your specific host's documentation for the config file location — it differs between applications and changes between versions.
Step 5 — Write your own client
This is what makes MCP click: connect a server to your agent from Chapter 51.
# my_client.py
import asyncio, json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python", args=["order_server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
# Some SDK versions expose initialize() for compatibility with
# older MCP revisions. For 2026-07-28, discovery/metadata are
# request-scoped; follow your installed SDK's current tutorial.
if hasattr(session, "initialize"):
await session.initialize()
# DISCOVER what the server can do
tools = await session.list_tools()
for t in tools.tools:
print(f"tool: {t.name} -- {t.description.splitlines()[0]}")
# CALL a tool
result = await session.call_tool("get_order", {"order_id": "ORD-1001"})
print("result:", result.content[0].text)
# READ a resource
res = await session.read_resource("policy://refunds")
print("policy:", res.contents[0].text[:80], "...")
asyncio.run(main())
To wire it into your agent: convert the MCP tool list into your provider's
tool-schema format, and route tool calls through session.call_tool. That's
roughly twenty lines, and then your Chapter 51 agent can use any MCP server
anyone has written.
Practice (60 min)
- Run the server and client above. Confirm both work.
- Add a tool —
search_orders(status)returning matching IDs. Notice how little code it takes once the server exists. - Connect a third-party server — try the official filesystem, fetch, or git reference servers. Point one at a scratch directory.
- Wire MCP into your Chapter 51 agent. Have it answer "How many orders does customer C-42 have, and what's in the most recent one?" — that needs two tool calls in sequence. Watch the loop do it.
- Break it deliberately: make
get_orderslow (time.sleep(30)). What happens? Now make it raise an exception. Does your agent recover?
Common confusions
- "Do I need MCP to use tools?" No (Chapter 56). Use it for reuse, isolation, or third-party servers.
- "Can an MCP server call the LLM?" Not in the core flow — the host owns the model. Keep the boundary clean.
- "Is stdio secure?" The server runs as a subprocess with your permissions. That's convenient and dangerous — see Chapter 61.
- "How do I do auth for remote servers?" The specification uses OAuth 2.1 (PKCE required, no implicit grant), and servers must validate the token audience. Chapter 61.
Check yourself
- What are the two transports, and when would you use each?
- In the example, why doesn't
request_refundactually issue a refund? - Why is the 100 EUR policy in Python instead of the tool description?
- What does a client do first after connecting?
Further reading
- Docs:
modelcontextprotocol.io— "Build an MCP server" and "Build an MCP client" tutorials. Follow both. - Code:
github.com/modelcontextprotocol/servers— read two or three reference servers. - Registry: the official MCP registry and community directories — browse what already exists before writing your own.
End of Part 13 — Milestone check
- I can explain the N×M problem MCP solves
- I can name the three roles and say which talks to the LLM
- I know the three server primitives and who controls each
- I have run an MCP server and called it by hand with JSON-RPC
- I have written my own MCP server
- I have connected a server to a host or my own client