Build a Server (stdio) and Connect a Client
Module 6 — Build a Server (stdio) and Connect a Client
Phase 2 · The Primitives & Building. Theory ends here. You will ship a working stdio MCP server that exposes a tool, a resource, and a prompt, then connect it to a host and to the MCP Inspector to watch the raw protocol you learned in Modules 2–3 flow by. The goal isn’t a fancy server — it’s the click of seeing your tools/call request and result on the wire. After this, MCP is something you do, not something you read about.
By the end you can:
- Stand up a minimal MCP server with an SDK (no hand-rolled JSON-RPC).
- Implement one tool (with structured output), one resource, one prompt.
- Connect it to a real host and debug it with the Inspector.
- Read the raw lifecycle + message flow and map it to Modules 1–5.
This module is a guide, not copy-paste production code. Use the official SDKs (TypeScript and Python are the most mature; SDKs exist for several other languages). API names shift across SDK versions — when a detail differs, trust the SDK’s current docs over any snippet here. Conventions below reflect the high-level “FastMCP”-style Python API and the TypeScript SDK as of the
2025-11-25era.
1. Setup
Pick one language. Both are first-class.
Python
# uv is the common choice; pip works too
uv init mcp-demo && cd mcp-demo
uv add "mcp[cli]" # the official Python SDK (includes the FastMCP high-level API)
TypeScript
mkdir mcp-demo && cd mcp-demo && npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
You’ll also want the MCP Inspector (official debugging UI), runnable on demand:
npx @modelcontextprotocol/inspector
2. A minimal server — Python (high-level API)
The high-level API hides the JSON-RPC plumbing: you declare tools/resources/prompts with decorators and it generates schemas from type hints.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo-server") # names the server (shows up in serverInfo)
# --- TOOL (model-controlled). Read tool: no side effects. ---
@mcp.tool()
def word_count(text: str) -> dict:
"""Count words and characters in a piece of text."""
words = len(text.split())
return {"words": words, "characters": len(text)} # structured output
# --- TOOL (write-ish). Honest about being a mutation. ---
@mcp.tool()
def save_note(title: str, body: str) -> dict:
"""Append a note to the in-memory notebook. Use for new notes only."""
NOTES.append({"title": title, "body": body})
return {"id": len(NOTES) - 1, "saved": True}
# --- RESOURCE (app-controlled). Read-only data at a URI. ---
NOTES: list[dict] = []
@mcp.resource("notes://all")
def all_notes() -> str:
"""All saved notes as JSON."""
import json
return json.dumps(NOTES, indent=2)
# --- RESOURCE TEMPLATE (parameterized). ---
@mcp.resource("notes://{note_id}")
def one_note(note_id: int) -> str:
import json
return json.dumps(NOTES[note_id])
# --- PROMPT (user-controlled). A reusable template with an argument. ---
@mcp.prompt()
def summarize_note(note_id: int) -> str:
"""Produce a prompt that asks the model to summarize a saved note."""
return f"Summarize the following note in one sentence:\n\n{NOTES[note_id]['body']}"
if __name__ == "__main__":
mcp.run() # defaults to stdio transport
Notice how cleanly this maps to Module 4’s control triad: @mcp.tool (model), @mcp.resource (app), @mcp.prompt (user). The SDK turns your type hints into the inputSchema JSON Schema the model uses — but you should still tighten schemas for anything real (enums, ranges, descriptions); auto-generated schemas are a starting point, not the finish line.
3. A minimal server — TypeScript (equivalent)
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-server", version: "1.0.0" });
const NOTES: { title: string; body: string }[] = [];
// TOOL (read) — zod schema becomes the inputSchema
server.registerTool(
"word_count",
{ description: "Count words and characters in text.",
inputSchema: { text: z.string() } },
async ({ text }) => ({
content: [{ type: "text", text: JSON.stringify({ words: text.split(/\s+/).length, characters: text.length }) }],
})
);
// TOOL (write)
server.registerTool(
"save_note",
{ description: "Append a note. Use for new notes only.",
inputSchema: { title: z.string(), body: z.string() } },
async ({ title, body }) => {
NOTES.push({ title, body });
return { content: [{ type: "text", text: JSON.stringify({ id: NOTES.length - 1, saved: true }) }] };
}
);
// RESOURCE
server.registerResource(
"all-notes", "notes://all", { description: "All notes as JSON" },
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(NOTES) }] })
);
// PROMPT
server.registerPrompt(
"summarize_note", { description: "Summarize a note", argsSchema: { noteId: z.string() } },
({ noteId }) => ({
messages: [{ role: "user", content: { type: "text", text: `Summarize note ${noteId} in one sentence.` } }],
})
);
await server.connect(new StdioServerTransport()); // stdio transport
Same three primitives, same control triad, different language. This is Module 1’s “language-agnostic protocol” claim made concrete: a host doesn’t care which file you wrote.
4. Run it under the Inspector (do this — it’s the point)
The Inspector is a local UI that acts as a client and shows you every message. Launch it pointed at your server:
# Python
npx @modelcontextprotocol/inspector uv run python server.py
# TypeScript (after building or via tsx)
npx @modelcontextprotocol/inspector npx tsx server.ts
In the Inspector you can:
- watch the
initializehandshake and see the negotiatedprotocolVersion+ capabilities (Module 3 made real); - hit List Tools / List Resources / List Prompts and see the JSON (
tools/listetc.); - call a tool and inspect the exact
tools/callrequest and the resultcontent(Module 2 made real); - force an error and watch the difference between a protocol error and a tool result with
isError: true.
Assignment: trigger every primitive, then copy the raw JSON-RPC for one full
tools/callround-trip into a scratch file and annotate each field. This single exercise welds Modules 2–4 together.
5. Connect it to a real host
A stdio server is registered with a host by telling the host how to launch it — typically a small JSON config naming the command and args, e.g.:
{
"mcpServers": {
"demo": { "command": "uv", "args": ["run", "python", "/abs/path/server.py"] }
}
}
The exact location of this config differs per host (desktop apps, IDEs, agent platforms each have their own), but the shape is universal: command + args + optional env. The host spawns your process (Module 3’s stdio lifecycle), does the handshake, and your tools appear. Now an actual model can call word_count and save_note, and you’ll see the consent gate (Module 1) in action for the write tool.
6. Common pitfalls (save yourself hours)
- Printing to stdout. On stdio, stdout is the protocol channel. Any stray
print()/console.log()corrupts the JSON-RPC stream. Log to stderr instead. This is the #1 beginner bug. - Loose schemas. Auto-generated schemas from type hints are permissive. Add enums, ranges, and descriptions for reliable tool calls.
- Blocking the event loop. Long synchronous work stalls the server; use async or offload (and preview Module 7’s progress/Tasks for long jobs).
- Forgetting capabilities. If a primitive doesn’t show up, check the server actually registered it and (for dynamic lists) advertises
listChanged. - Absolute paths in host config. Hosts launch your server from an unpredictable working directory — use absolute paths.
7. Milestone
You’re done with Module 6 when:
- Your server runs under the Inspector and every primitive (2 tools, 1 resource, 1 prompt) works.
- You captured one full
tools/callround-trip’s raw JSON and annotated every field. - You connected the server to a real host and saw a model call your tool (and a consent prompt for the write tool).
- You can point at the Inspector’s handshake and name the negotiated version + capabilities.
Ship that and you’ve closed the loop from theory to working software. Next: Module 7 — Async work & advanced UX (Tasks), for operations that outlive a single request.
Quick reference — Module 6 glossary
- Official SDKs — use them; don’t hand-roll JSON-RPC. TS + Python are most mature.
- High-level API (FastMCP-style) — declare tools/resources/prompts with decorators; schemas generated from type hints (still tighten them).
- MCP Inspector — official client/debugger UI; shows raw lifecycle + messages.
- stdout = protocol on stdio — never print to stdout; log to stderr.
- Host config — registers a stdio server via
command+args(+env); host spawns the process and handshakes.