Part 5 of 6 — ← Part 4: MCP vs Everything Else
A guided minimal lab — one eCommerce server, one client, and a complete MCP example you can inspect end to end.
Parts 1 through 4 covered the mental model — what MCP is, how the request flow works, where it fits in the stack. This part builds the thing.
This is a guided minimal lab — the smallest complete MCP system that shows how a client connects, how a server exposes capabilities, and how the protocol exchange actually works in practice.
Full runnable code and local setup instructions are in the GitHub repository. This article explains why things are built the way they are.
Full source on GitHub: the Part 5 folder includes
server.py,client.py, a data seed script, and a README with complete local setup instructions. → github.com/gursharanmakol/part5-order-assistant
Try it in three steps
- Clone the repo and run
bash run.sh - Start Inspector with
npx @modelcontextprotocol/inspector python server.py - Add the server to Claude Desktop and ask about order
ORD-10042
That sequence mirrors the article — build it, inspect it, then use it through a real host.
What We Are Building
A single MCP server connected to a local seeded order data file. One client that connects to it over stdio. The server exposes seven MCP capabilities: three tools, two resources, and two prompts.
Before diving into the code, it helps to understand what those three categories actually mean — because they are not interchangeable, and the distinction is the whole point.
Tools are functions the model can call. When multiple tools are exposed, the model chooses among them based on the metadata the host provides — especially the tool name, description, and input schema. When a user asks "what is the status of my order?", the model may decide to invoke get_order_status. It passes an argument, gets a result, and uses that result to help form its response. Tools can read data or change it — get_order_status is read-only, cancel_order is not.
Resources are read-only data the host application exposes as context. The host application here means the MCP-aware app using the server — for example Claude Desktop, Inspector, or your own client. Resources may represent static content like a file or configuration object, or dynamic read-only content like a specific record or a computed summary view. The model does not call a resource the way it calls a tool — the host decides when to fetch it and make it available as background information. In this lab, order://{id} represents one specific order record, while recent-orders://summary represents a read-only summary view of recent orders.
Prompts are reusable, parameterized instruction templates exposed by the server. Instead of writing a new instruction each time, the client can pass a value like order_id to a prompt that already exists. For example, a prompt named summarize_order might represent an instruction like: "Summarize order {order_id}. Include status, carrier, delivery estimate, item count, and a short customer-friendly explanation." The server fills in that template and returns prepared messages the model can work from. It is closer to a macro than a message.
Here is what the server exposes:
| Tools (model decides) | Resources (app decides) | Prompts (user decides) |
|---|---|---|
get_order_status(order_id) |
order://{id} |
summarize_order |
get_order_items(order_id) |
recent-orders://summary |
customer_friendly_response |
cancel_order(order_id) |
Same server. Three different roles: the model selects tools, the host loads resources, and a client or user invokes prompts. Worth understanding before you start implementing.
cancel_orderis deliberately included. Most MCP examples show read-only tools. A destructive action makes clear that MCP handles execution, not just retrieval.
The Server
The server is a single Python file. The SDK uses decorators to tell the server what each function represents: @app.tool() exposes a tool, @app.resource(...) exposes a resource, and @app.prompt() exposes a prompt. It runs over stdio transport. The structure below shows the shape — full implementation is in the repository:
app = FastMCP("order-assistant")
# Tools — model decides when to call these
@app.tool()
async def get_order_status(order_id: str) -> dict: ...
@app.tool()
async def get_order_items(order_id: str) -> dict: ...
@app.tool()
async def cancel_order(order_id: str) -> dict: ...
# Resources — app decides when to expose these
@app.resource("order://{id}")
async def order_resource(id: str) -> str: ...
@app.resource("recent-orders://summary")
async def recent_orders_summary() -> str: ...
# Prompts — user decides when to invoke these
@app.prompt()
async def summarize_order(order_id: str) -> str: ...
@app.prompt()
async def customer_friendly_response(order_id: str) -> str: ...
Three decorators, three capability types. Each decorated function becomes discoverable by any MCP client that connects — the SDK handles the registration, the protocol handles the rest.
Tools also accept a title field — a human-readable display name separate from the functional name. The name is what the model uses to invoke the tool. The title is what host UIs show to people.
Adding more tools does not change the protocol. It only expands the server's list of capabilities. The
initialize → list → callsequence is identical whether your server exposes one tool or twenty.
The Line the Model Actually Reads
Every tool has an implementation and a description. The implementation is what runs. The description is what the LLM reads to decide whether to run it at all.
@app.tool()
async def get_order_status(order_id: str) -> dict:
"""
Retrieve the current status and shipping information for a customer order.
Use this when the user asks about a specific order by ID, order number,
or reference code. Returns status, carrier, and estimated delivery date.
Do not use this for general product availability questions.
"""
A well-implemented tool that is never invoked is a silent failure. The description is the LLM's decision interface — too broad and the model calls it for unrelated queries, too narrow and it misses valid triggers. The final line ('Do not use this for...') is as important as the first. Write it as a spec, not a label.
I have seen this trip up experienced developers. The implementation works perfectly. The tool never gets called. The description was the bug the whole time.
The same applies to cancel_order. That description must be explicit that the action is irreversible and that the model should confirm with the user before invoking. The MCP spec formalizes this with tool annotations — optional hints that signal tool behavior to host applications:
@app.tool(
title="Cancel Order",
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
)
async def cancel_order(order_id: str) -> dict:
"""
Cancel a customer order. This action is irreversible.
Confirm with the user before invoking.
Do not call this tool based on an ambiguous request.
"""
Spec note (2025-11-25): The spec defines
readOnlyHint: truefor tools that only read data anddestructiveHint: truefor tools that may permanently change state. Host applications use these hints to show warnings, require approval steps, or restrict access. In an agentic system, a vague description on adestructiveHint: truetool is a correctness bug, not a style issue.
The Client
The client connects to the server, runs the initialization handshake, discovers what the server exposes, and invokes a tool. Three steps — and the order is not arbitrary.
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Step 1 — Initialize: capability negotiation happens here
await session.initialize()
# Step 2 — Discover: what does this server expose?
tools = await session.list_tools()
resources = await session.list_resources()
prompts = await session.list_prompts()
# Step 3 — Invoke: call a tool with arguments
result = await session.call_tool(
"get_order_status",
arguments={"order_id": "ORD-10042"}
)
Initialize. List. Call. Each step depends on the previous one. The server cannot advertise its capabilities before the handshake completes. In normal MCP flow, the client discovers capabilities before invoking them. That ordering is the protocol. If you followed Part 3, you saw this sequence described. Here it actually runs.
The full client — resource reads, prompt invocations, and error handling — is in the repository.
This client makes the protocol visible. In practice, a host like Claude Desktop handles discovery and tool use behind the scenes — you ask a question, and the host works from what the server exposes to decide whether a tool should be invoked. The three-step pattern here is what that process looks like under the hood.
Watching the Protocol: MCP Inspector
MCP Inspector is a browser-based tool that connects to your server and shows the raw JSON-RPC exchange in both directions. It is the practical equivalent of Postman for the MCP protocol — you can see every message the client sends and every response the server returns, without writing any client code and without connecting Claude Desktop.
Run it against the server:
npx @modelcontextprotocol/inspector python server.py
Inspector opens at http://localhost:5173. Connect, then watch the three exchanges that define every MCP interaction.
Always test with MCP Inspector before connecting Claude Desktop. If a tool does not appear in Inspector's Tools tab, it will not appear in Claude. Inspector is where you debug — not the Claude Desktop logs.
I tested this in Inspector first because Claude Desktop hides the protocol too well when you are still learning. Inspector makes the handshake visible.
Exchange 1 — initialize: Capability Negotiation
The client opens the connection and declares its protocol version and capabilities. The server responds with its own identity and what it supports:
// Client → Server
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"clientInfo": { "name": "order-client", "version": "1.0" },
"capabilities": { "roots": { "listChanged": true } }
}
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"serverInfo": { "name": "order-assistant", "version": "1.26.0" },
"capabilities": {
"tools": { "listChanged": true },
"resources": { "listChanged": true },
"prompts": { "listChanged": true }
}
}
}
The capabilities block is the negotiation. tools: { listChanged: true } means this server will notify connected clients if its tool list changes at runtime — no polling required. The client now knows what this server supports before invoking anything.
Exchange 2 — tools/list: Discovery
The client asks what tools exist. The server returns each tool's name, title, description, annotations, and input schema — the same tool metadata a host provides to the model when making tool decisions:
// Server → Client
{
"tools": [
{
"name": "get_order_status",
"title": "Order Status Lookup",
"description": "Retrieve the current status and shipping information...",
"inputSchema": {
"type": "object",
"properties": { "order_id": { "type": "string" } },
"required": ["order_id"]
}
},
{
"name": "cancel_order",
"title": "Cancel Order",
"description": "Cancel an order. This action is irreversible. Confirm with user before invoking.",
"annotations": { "destructiveHint": true, "readOnlyHint": false },
"inputSchema": {
"type": "object",
"properties": { "order_id": { "type": "string" } },
"required": ["order_id"]
}
}
]
}
Notice title alongside name — human-readable label for UIs, separate from the functional identifier the model uses. And annotations on cancel_order, visible in the response. In Inspector, open the Tools tab and you will see this list rendered. The description field is the key metadata the host exposes to the model for tool selection. Seeing it here gives you a reasonable approximation of what the model is working with.
Exchange 3 — tools/call: Execution
The client invokes get_order_status with an order ID. The server reads the local seeded order data and returns the result:
// Client → Server
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_order_status",
"arguments": { "order_id": "ORD-10042" }
}
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{
"type": "text",
"text": "{\"order_id\": \"ORD-10042\", \"status\": \"shipped\", \"carrier\": \"FedEx\", \"delivery_estimate\": \"2026-03-28\"}"
}],
"isError": false
}
}
The result is returned as text here to keep the example readable. The November 2025 spec also supports outputSchema and a structuredContent field for responses like this, enabling clients to validate structured results programmatically — which becomes more important in production-oriented designs.
That is the complete MCP interaction — the same sequence that runs every time a model invokes a tool in a real host. Three exchanges. One consistent pattern regardless of what the server exposes or what system it wraps.
A Note on Errors
The spec distinguishes two failure modes. A Protocol Error means the request itself was malformed — wrong tool name, invalid JSON structure. A Tool Execution Error means the tool ran but the operation failed — the order was not found, the file could not be read, the cancellation was rejected. These are returned differently:
// Tool Execution Error — returned inside a successful result
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [{ "type": "text", "text": "Order ORD-10042 not found." }],
"isError": true
}
}
The distinction matters because tool execution errors include feedback the model can use to self-correct and retry with adjusted parameters. Protocol errors indicate a structural problem the model is less likely to recover from. Full error handling is in the repository.
Connecting to Claude Desktop
Once Inspector confirms the server works, register it with Claude Desktop.
-
macOS:
~/Library/Application Support/Claude/claude_desktop_config.json -
Windows:
%APPDATA%\Claude\claude_desktop_config.json
The JSON structure is the same on every platform. Only the path values change: macOS and Linux use forward slashes, while Windows paths require escaped backslashes in JSON — C:\\path\\to\\server.py.
// macOS / Linux
{
"mcpServers": {
"order-assistant": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"DATA_PATH": "/absolute/path/to/data/orders.json"
}
}
}
}
// Windows
{
"mcpServers": {
"order-assistant": {
"command": "python",
"args": ["C:\\absolute\\path\\to\\server.py"],
"env": {
"DATA_PATH": "C:\\absolute\\path\\to\\data\\orders.json"
}
}
}
}
Three configuration details prevent most connection failures:
- Absolute paths only. Claude Desktop launches the server process from an unpredictable working directory. Relative paths are a common cause of hard-to-diagnose failures.
-
Credentials in env, not args. The
envblock is the right place for runtime configuration such as data paths, API keys, and connection settings. - Restart Claude Desktop after every config change. There is no hot reload.
After restart, ask Claude about order ORD-10042. The three exchanges you watched in Inspector are happening behind that response — initialize, discover, invoke — the same sequence, now driven by the model.
How This Scales
This server wraps one bounded capability surface: order data exposed through a local seeded file. In practice, many MCP servers follow that pattern. In a real eCommerce stack, you would have separate servers for Stripe, the CRM, the shipping provider, and the product catalog — each focused on one system or one domain.
The client code does not change. The protocol does not change. Each new server goes through the same initialize → list → call sequence. Each server gets its own dedicated client connection inside the host — one client per server, not one client managing everything. Adding a Stripe server means adding a Stripe entry to the config and writing a Stripe-specific server file. Nothing else changes at the protocol level.
The protocol is fixed. The capabilities are not. You extend an MCP system by adding servers — each exposing the tools, resources, and prompts relevant to one system. The same interaction pattern applies to every server you add.
Two features from the November 2025 spec are worth knowing exist, even if they are out of scope for this lab. outputSchema lets a tool declare the JSON Schema of its return value — useful when clients need to validate structured results programmatically. The Tasks primitive enables asynchronous, long-running tool execution — a server creates a task handle, publishes progress, and delivers results when the operation completes. Both matter more in production-oriented designs and sit outside this lab.
The server you built today follows the same contract as any other MCP-compliant server. Any MCP-compatible host can discover and use it without custom integration code.
Three Takeaways
1. The description is the interface. The tool description is the LLM's only view of what a tool does. A well-implemented tool that is never invoked is a silent failure. Write the description as a spec — include when to call it, what it returns, and when not to call it.
2. The pattern is three steps. Initialize → list → call is the complete MCP interaction pattern. Each step depends on the previous one. Once you understand this sequence, the rest of the protocol is mostly detail.
3. Scale by adding servers, not capabilities. Adding more capabilities does not change the protocol. Usually, you scale an MCP system by adding servers rather than turning one server into a catch-all. The host manages the connections. The pattern holds.
MCP reduces the cost of connecting systems. It does not reduce the responsibility of designing them correctly.
Part 6 moves from this clean lab setup into production reality: transport choices, auth, least privilege, governance, and what happens when MCP servers begin to spread across an organization. If there is a production MCP topic you would like covered in Part 6, let me know in the comments.
Top comments (0)