Chapter 6 of 9 · Model Context Protocol
Build a server, in the page
by the end of this chapter you've authored a working MCP server.
You have the wire from chapter 3, the handshake from chapter 4, and the three primitives — tools, resources, prompts — from chapter 5. What you don't have, yet, is a server. The four-message exchange that's been flying through the diagrams was always between two real processes; one of them is what this chapter builds. The build is small enough to fit in one page, and you won't leave this tab to do it.
Before we open the editor, a quick check on what the host actually sees when your server replies. Most readers arrive thinking the tool name does the heavy lifting. The spec disagrees — and so will your model.
The frame, in one paragraph#
An MCP server is a process that registers a small set of named primitives and answers JSON-RPC requests about them. The official SDK — @modelcontextprotocol/sdk in TypeScript — does the JSON-RPC plumbing for you: you call registerTool, registerResource, and registerPrompt; the SDK turns those calls into the responses for tools/list, resources/list, prompts/list and their corresponding read / call / get methods. No socket code, no JSON parsing — you describe what the server has, and the SDK carries it onto the wire.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
import { z } from "zod";
const server = new McpServer({ name: "currency-server", version: "0.1.0" });
server.registerTool(
"convert",
{
description: "Convert an amount from one currency to another.",
inputSchema: { from: z.string(), to: z.string(), amount: z.number() },
},
async ({ from, to, amount }) => {
const rate = await rates.get(from, to);
return { content: [{ type: "text", text: String(rate * amount) }] };
},
);
server.registerResource("rates", "rates://{date}", {
mimeType: "application/json",
description: "FX rates for an ISO date.",
}, async ({ date }) => ({ contents: [{ uri: `rates://${date}`, mimeType: "application/json", text: JSON.stringify(await rates.snapshot(date)) }] }));
server.registerPrompt("recommend-currency-mix", {
description: "Suggest a multi-currency holding split.",
arguments: [{ name: "country", required: true }, { name: "horizon" }],
}, async ({ country, horizon }) => ({
messages: [{ role: "user", content: { type: "text", text: `Recommend a mix for ${country} over ${horizon ?? "12m"}.` } }],
}));
await server.connect(new StdioServerTransport());That listing is the end-state — the file you'd write outside this page. We'll build it with our hands in a second. The widget below isn't this exact file in a sandbox, but a parsed-stub equivalent — you fill in registrations through a small form, and the page synthesizes the JSON-RPC responses the SDK would emit from those declarations. The lesson is registration: what the model sees, what the host validates against, what the URI template addresses. Handler bodies are a downstream concern — you can write them in a real editor after the chapter, and the file above is your starting point.
A server in your hands#
Open it up. The editor starts with the worked example loaded — a currency-server exposing one tool (convert), one resource (rates://{date}), and one prompt (recommend-currency-mix). Edit any of the three, then fire a request from the "try it" panel. The response is what a real host would receive over stdio.
Three things land while you're poking at it. First — calling tools/list returns the description text verbatim. That's the model's only signal for when to call your tool; if you blank it out and re-run the call, the entry shows up but reads as a black box. Second — calling tools/call without a required argument doesn't reach a handler. The host validates the arguments against the schema first; the synthesizer echoes the validation error the SDK would have thrown. Third — a URI template like rates://{date} isn't a list endpoint. There's no rates://list — discovery is resources/list, access is resources/read with a substituted URI, and the spec keeps those two surfaces cleanly apart.
One tool, end to end
Pick the tools tab. The converttool carries a name, a description, and an input schema with three required fields. Switch the "try it" method to tools/list and send. Note what the model gets back — a JSON Schemadescribing each input, plus your description. The schema isn't Zod on the wire; the SDK translates Zod into JSON Schema so any client can validate against it without a runtime dependency on Zod itself. Now switch to tools/call, pick convert, and fill in the three fields. The synthesized response is a content array— the SDK's canonical reply shape for tool calls — with a single text item describing what your handler would have returned.
One resource
Switch to the resources tab. The reader will see rates://{date} — a URI template, not a concrete URI. The placeholder in braces becomes a parameter the host substitutes when it asks to read. Send a resources/list and you'll see the template, the MIME type, and the description. Now switch to resources/read and replace the placeholder with 2026-04-30. The synthesizer matches your URI back against the template and synthesizes a response the application would feed to the model. Try a URI that doesn't match — the response carries an error, not a list. Resources are content-addressable, not enumerable.
One prompt
The prompts tab carries recommend-currency-mix — the user-controlled surface from chapter 5's controller table. A prompt has a name, a description, an argument list, and a body template with {arg} substitutions. Switch to prompts/get, pick the prompt, fill in country=JP and horizon=6m, and send. The response is a messages array — pre-formed conversation turns the user invokes via a slash command in their host.
The discipline behind the shapes#
Three rules survive when you peel away the form and look at the bytes on the wire. Descriptions matter: they're the only thing the modelreads to decide which tool to call. An empty description registers cleanly and is invisible in practice; a misleading one hijacks the model's decisions (chapter 9 will cash this in as an attack surface). Schemas matter: the hostvalidates arguments against the input schema before the handler runs. Validation errors are the spec's — not yours; you don't write them. URI templates matter: a resource isn't a named record but a content-addressable shape. Hosts list templates with resources/list and read concrete URIs with resources/read — discovery is one method, access is another, and the spec is explicit about the separation.
Comprehension check#
You register a resource at notes://{id} and the user asks the host to call notes/list. What happens?
reveal answer
Nothing — there is no notes/list method in the spec. Discovery on the resource axis is resources/list, which returns your template; access is resources/read with a concrete URI like notes://abc. The two surfaces are deliberately separate: one for "what do you have?", the other for "give me this specific thing." A host that conflates them gets -32601 method not found — and the spec is right to refuse.
The server is standing. Now what?#
You registered three things, fired six requests, and watched the JSON-RPC shapes land. The descriptions on your tools are what the model reads; the schemas are what the host enforces; the URI templates address content the application can fetch on the model's behalf. Nothing in there is mysterious — the SDK handles the wire, you handle the registrations, and the four- message handshake from chapter 4 carries the menu across.
Which leaves the question we've been deferring since chapter 3. The server is standing. Who's callingit? The host spawned a process and got bytes back over a pipe. We need a client — and we need to pick a transport while we're at it. That's chapter 7.