Module 06

Build a Server (stdio) and Connect a Client

Building Systems Model Context Protocol (MCP)

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:

  1. Stand up a minimal MCP server with an SDK (no hand-rolled JSON-RPC).
  2. Implement one tool (with structured output), one resource, one prompt.
  3. Connect it to a real host and debug it with the Inspector.
  4. 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-25 era.


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 initialize handshake and see the negotiated protocolVersion + capabilities (Module 3 made real);
  • hit List Tools / List Resources / List Prompts and see the JSON (tools/list etc.);
  • call a tool and inspect the exact tools/call request and the result content (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/call round-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/call round-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.