MCP in Production · Chapter 2

Anatomy of a Server

If you remember one thing from this chapter: tools are 90% of what you'll ever need.

I audited the fourteen MCP servers I ship under @yawlabs -- tailscale, npmjs, aws, electron, ctxlint, lemonsqueezy, and the rest. I counted every primitive each one exposes. Tools, resources, prompts. The numbers came out to roughly 90% tools, 8% resources, 2% prompts. That ratio is not a coincidence and it is not a Yaw Labs idiosyncrasy. I have looked at the public Anthropic reference servers, the GitHub MCP server, the Slack MCP server, the Postgres MCP server, and a long tail of community servers, and they all settle into the same shape.

If you are coming from REST API design, this will feel wrong at first. REST taught you to think in nouns. Resources are nouns. Tools sound like verbs, and verbs are second-class. But MCP is not REST. The consumer is a language model, not a human writing client code. The model wants verbs. It wants to do things. It does not want to browse a tree of nouns and figure out which GET/POST combination achieves the goal. So when you sit down to design a server, your first move is almost always the same: list the verbs. Tools first. Everything else can wait, and most of the time, everything else does not need to happen at all.

This chapter is the conceptual reference for the protocol's primitives. Chapter 3 will walk through a worked example end to end. Chapter 4 will dig into design patterns I have learned from running these servers in production. Right now I want you to leave with a clear mental model of what an MCP server is, primitive by primitive, with enough TypeScript in front of you to recognize the API when you see it.

The Three Primitives

The MCP spec defines three things a server can offer to a client:

The 90/8/2 ratio is empirical. I did not set out to write servers that lean on tools; the design pressure pushed me there. Every time I have shipped a resource or a prompt I have either later deleted it or wished I had shipped a tool instead.

The rest of this chapter is structured the same way. Long section on tools. Short section on resources. Even shorter on prompts. Then lifecycle, transports, notifications, and a taxonomy of failure modes by layer.

Tools

A tool is the unit of work in MCP. The model decides to call one, the server runs the handler, and the result flows back to the model as part of its context. That round trip is the whole game.

In the official TypeScript SDK -- @modelcontextprotocol/sdk 1.x -- you register a tool on an McpServer instance. There is also a lower-level Server class, but I have not used it in any of my fourteen servers and I would not recommend it for anything you actually want to ship. McpServer is the high-level entry point, and "high-level" here means "it auto-derives your capability negotiation, validates input against your schema, and gives you a sane error path." All things you would otherwise have to write yourself.

Here is the smallest tool I can show you that does something real. This is from @yawlabs/tailscale-mcp, simplified.

tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; const server = new McpServer({ name: "tailscale-mcp", version: "0.3.0", }); server.registerTool( "list_devices", { description: "List all devices in the current Tailscale tailnet. Returns " + "hostname, IP, OS, online status, and tags for each device. " + "Use this when the user asks about their network, what machines " + "are connected, or to find a specific device by name.", inputSchema: {}, }, async () => { const devices = await tailscaleClient.listDevices(); return { content: [ { type: "text", text: JSON.stringify(devices, null, 2), }, ], }; } );

That is the whole shape. Four parts: name, description, input schema, handler. Let me take each one in order, because every one of them has subtleties that will bite you in production.

Name

Tool names are the symbol the model will use to call you. They are also user-visible in most clients (Claude Code prints them, Inspector prints them, custom clients tend to surface them). Conventions matter:

Names are also stable contracts. Once you ship list_devices, renaming it is a breaking change for any client config that hardcodes it. Treat it like a public API, because it is one.

Description

This is the single most under-appreciated design surface in MCP. The description is your contract with the model. It is the only thing the model sees -- along with the schema -- when it decides whether to call your tool. Not your README, not your repo, not your comments. The description.

I have rewritten tool descriptions in my servers more times than I have rewritten the actual handler logic. The first version is always too short. The second version is usually too long. The third version says, in this order:

  1. What the tool does. One sentence. Verb-first.
  2. What it returns. Concrete shape, not abstract intent.
  3. When to call it. This is the part everyone forgets. The model is making a decision under uncertainty about which tool to pick. Telling it the trigger conditions cuts the wrong-tool error rate in half.

Here is the same list_devices description split across those three:

tsdescription: // 1. What it does "List all devices in the current Tailscale tailnet. " + // 2. What it returns "Returns hostname, IP, OS, online status, and tags for each device. " + // 3. When to call it "Use this when the user asks about their network, what machines " + "are connected, or to find a specific device by name.",

From the field: I once shipped @yawlabs/npmjs-mcp with a tool called search_packages whose description was just "Search the npm registry for packages." Models would call it for everything -- "find my package," "list my packages," "look up the latest version of X." All of those have better tools (list_my_packages, get_package). The fix was changing the description to "Free-text search for npm packages by keyword. Use ONLY when the user does not know the exact package name; prefer get_package for known names." Wrong-tool calls dropped almost overnight.

The description is also where you put usage caveats the model needs at decision time. "This is destructive" goes in annotations (we will get there), but "This costs money on the user's AWS bill, prefer dry_run: true first" goes in the description.

Input Schema

The SDK accepts schemas as Zod objects, then translates them to JSON Schema for the wire protocol. You can hand-write JSON Schema if you want; nobody does, because Zod is just nicer.

tsimport { z } from "zod"; server.registerTool( "get_device", { description: "Get full details for a single Tailscale device by ID.", inputSchema: { deviceId: z .string() .describe( "The Tailscale device ID (numeric string, e.g. '12345678901234'). " + "Find this via list_devices." ), }, }, async ({ deviceId }) => { const device = await tailscaleClient.getDevice(deviceId); return { content: [{ type: "text", text: JSON.stringify(device, null, 2) }], }; } );

Two things to notice. First, inputSchema is an object whose values are Zod schemas, not a single Zod object. The SDK builds the outer object schema for you. Second, every parameter has a .describe() call.

The .describe() pattern is non-negotiable. The string you pass to .describe() ends up in the JSON Schema that the model sees. Without it, the model sees { "deviceId": { "type": "string" } } and has to guess what a deviceId looks like. With it, the model sees the format, the example, and a hint about how to obtain one. The difference between a tool that works and a tool that hallucinates UUIDs is often a .describe() call.

I write descriptions for every parameter, even ones that look obvious. "Page number, 1-indexed" beats "page" every time. "Repository name in owner/repo format" beats "repo." The model will follow your conventions if you tell it what they are.

For more complex shapes, Zod gives you the same affordances as TypeScript. Discriminated unions, refinements, defaults, optionals. Use them.

tsinputSchema: { // Optional with a default pageSize: z .number() .int() .min(1) .max(100) .default(25) .describe("Number of results per page (1-100, default 25)"), // Enum status: z .enum(["online", "offline", "all"]) .default("all") .describe("Filter by device status"), // Optional, can be omitted entirely tag: z .string() .optional() .describe("If provided, return only devices with this tag (e.g. 'tag:server')"), },

Pitfall: do not use z.any() or z.unknown() for input parameters. The model treats unbounded inputs as a license to send anything, and it usually picks badly. If your tool takes a free-form payload, define the shape as best you can; if you really cannot, take a structured wrapper instead and let the user populate fields. I have never once been glad I shipped a z.any() parameter.

Handler

The handler is the function that runs when the tool is called. It receives the validated input and returns a result. Three return shapes matter.

Success (text content):

tsasync ({ deviceId }) => { const device = await tailscaleClient.getDevice(deviceId); return { content: [ { type: "text", text: JSON.stringify(device, null, 2) }, ], }; }

Error (model-visible):

tsasync ({ deviceId }) => { const device = await tailscaleClient.getDevice(deviceId); if (!device) { return { isError: true, content: [ { type: "text", text: `Device not found: ${deviceId}. ` + `Use list_devices to see available IDs.`, }, ], }; } return { content: [{ type: "text", text: JSON.stringify(device, null, 2) }], }; }

Structured content (recommended for anything machine-shaped):

tsasync ({ deviceId }) => { const device = await tailscaleClient.getDevice(deviceId); return { content: [ { type: "text", text: `Found device ${device.hostname}` }, ], structuredContent: device, }; }

The structuredContent field is newer (added in the 2025 protocol revision) and worth knowing about. It carries a typed payload alongside the human-readable text. Clients that understand it can render rich UI; clients that do not fall back to the text. I now ship structuredContent on every tool that returns structured data, with a text field that is a one-line summary the model can read directly without having to deserialize JSON.

Pitfall: do not throw exceptions out of a handler. The MCP transport will catch the throw and surface it as a protocol-level error, which the model frequently cannot see in any useful form -- the error becomes opaque "tool failed" text without your message. Always return { isError: true, content: [...] } for expected errors. Save throws for genuinely unexpected conditions where you want the transport to crash and restart.

Concretely, here is my pattern for handler error handling:

tsserver.registerTool( "create_acl", { description: "Create a new Tailscale ACL rule.", inputSchema: { action: z.enum(["accept", "drop"]).describe("ACL action"), src: z.array(z.string()).describe("Source matchers"), dst: z.array(z.string()).describe("Destination matchers"), }, }, async ({ action, src, dst }) => { try { const rule = await tailscaleClient.createAcl({ action, src, dst }); return { content: [ { type: "text", text: `Created ACL rule ${rule.id}.`, }, ], structuredContent: rule, }; } catch (err) { // Expected errors -> isError, model sees the message if (err instanceof TailscaleApiError) { return { isError: true, content: [ { type: "text", text: `Tailscale API rejected the rule: ${err.message}. ` + `Common causes: invalid src/dst syntax, conflicting rule, ` + `insufficient permissions on the API key.`, }, ], }; } // Unexpected errors -> rethrow, transport handles it throw err; } } );

The isError: true path is the model's chance to recover. It can read your error message, understand what went wrong, and try a different approach. A thrown exception is just noise.

Annotations

Annotations are metadata hints that travel alongside a tool definition. They do not affect protocol behavior; they are signals to clients about how to render the tool, whether to ask for confirmation, whether to allow it in restricted modes. The current set:

tsserver.registerTool( "list_devices", { description: "List all devices in the tailnet.", inputSchema: {}, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true, // hits the Tailscale API }, }, async () => { /* ... */ } ); server.registerTool( "delete_device", { description: "Permanently remove a device from the tailnet. The device loses " + "access immediately and must re-authenticate to rejoin.", inputSchema: { deviceId: z.string().describe("Device ID to delete") }, annotations: { readOnlyHint: false, // explicit; the default is also false but // setting it makes the contract unambiguous destructiveHint: true, idempotentHint: true, // second call is a no-op on state; the 404 is a response, not a new effect openWorldHint: true, }, }, async ({ deviceId }) => { /* ... */ } );

I annotate every tool. Cost is two lines, benefit is that clients with confirmation flows -- and the more cautious users in those clients -- get the right prompts. If you are shipping a server with any destructive tools, this is table stakes.

Resources

A resource is read-only addressable content, identified by a URI. The client can list resources, read a specific resource by URI, and subscribe to updates.

tsserver.registerResource( "current-acl", "tailscale://acl/current", { description: "The currently deployed Tailscale ACL document.", mimeType: "application/hujson", }, async () => { const acl = await tailscaleClient.getCurrentAcl(); return { contents: [ { uri: "tailscale://acl/current", mimeType: "application/hujson", text: acl.raw, }, ], }; } );

That looks reasonable. It is also the kind of thing you should think hard about before shipping.

Resources have a discoverability story (resources/list enumerates them) and a content story (resources/read returns the body). The protocol supports subscriptions and updates, so a resource can change and notify clients. In theory this is great for static reference data the model wants to consult on demand: schemas, configs, documentation, audit logs.

In practice, in 2026, most clients do not surface resources well. Claude Code shows them in a side panel that I rarely see anyone open. Other clients ignore them entirely. And the model itself does not browse resources unprompted -- it has to be told about them, and the natural way to tell it about them is... to write a tool description that lists them. At which point you might as well have shipped tools.

From the field: I shipped @yawlabs/tailscale-mcp v0.1 with four resources: current ACL, current device list, current users list, current DNS config. Telemetry showed zero reads in the first three weeks. The model wasn't browsing resources; it was calling tools. I deleted the resources in v0.3, replaced them with get_acl, list_devices, list_users, get_dns tools, and saw immediate uptake. Nobody noticed the resources were gone. They were dead weight from day one.

Resources earn their keep in three narrow cases:

  1. Discoverability of static reference data. A schema document, a list of valid enum values, a glossary. Things the user might want to inspect manually in a UI even if the model never reads them.
  2. Audit trail surface. Read-only history that does not fit cleanly into a tool return. Some servers use resources to expose a rolling log.
  3. Files-as-first-class. Servers that wrap a filesystem-shaped backend (the official filesystem reference server is the canonical case). Here resources align with the underlying mental model.

If your server does not fall into one of those cases, default to no resources. Ship tools. You can always add resources later. You will rarely want to.

Prompts

Prompts are parameterized templates the user can invoke from the client UI -- typically as slash commands. The user picks a prompt, fills in any parameters, and the client expands the template into a message that gets sent to the model.

tsserver.registerPrompt( "diagnose_subnet", { description: "Walk through diagnosing connectivity issues on a Tailscale subnet.", arguments: [ { name: "subnet", description: "CIDR or subnet name", required: true, }, ], }, async ({ subnet }) => ({ messages: [ { role: "user", content: { type: "text", text: `Diagnose Tailscale connectivity for subnet ${subnet}. ` + `Start by listing devices in or routing to this subnet, ` + `then check ACL rules, then check DNS, then check the ` + `subnet router status.`, }, }, ], }) );

Prompts are the third primitive and they are the rarest one. Across fourteen @yawlabs servers I have shipped exactly one prompt -- in @yawlabs/aws-mcp -- and I am genuinely not sure it gets used. Most clients surface prompts somewhere ("/" in Claude Code), but the discovery story is weak: users who want to start a conversation usually just type, and the value of a one-shot template is low compared to the friction of remembering it exists.

Prompts make sense when:

Even then, I would write the workflow as a tool with structured guidance baked into the description before reaching for prompts. The model is good at following multi-step instructions in tool descriptions. Users are not great at remembering slash commands.

If you skip prompts entirely on your first server, you will not miss them.

The Lifecycle Handshake

When a client connects to your server, three messages happen in order before any tools run.

  1. Client sends initialize with its protocol version and client capabilities.
  2. Server responds with its protocol version, server capabilities, and a server info block.
  3. Client sends notifications/initialized -- a notification, no response needed -- which signals the server can start sending events.

Until step 3 lands, the connection is in a half-open state. Tools, resources, and prompts must not be called. Notifications must not flow.

client server | | |---- initialize --------> | | | | <-- initialize result ---| | | |---- initialized (notif) --> | | | |---- tools/list --------> | (now allowed) | |

If you use McpServer, the SDK handles all of this for you. You will rarely write the handshake yourself. The reason it matters is that capability negotiation happens here, and the failure modes here are common and confusing.

Capability Negotiation

The server tells the client what it supports. With McpServer, this is auto-derived from what you have registered:

You can also report sub-capabilities -- whether your tool list can change at runtime (tools.listChanged), whether resources support subscriptions (resources.subscribe), and so on. McpServer derives these too, based on whether you have wired up the corresponding APIs.

From the field: the most common protocol bug I see is "client called tools/list but server returned an error saying tools aren't supported." The cause is almost always: the server registered tools but McpServer was instantiated with explicit capabilities that did not include tools, overriding the auto-derivation. If you are using McpServer, do not pass an explicit capabilities object unless you are deliberately overriding. Let the SDK do its job.

The "Not Initialized" Failure Mode

If a client sends tools/list before sending initialized, the server should reject the request. The SDK enforces this. What this means in practice is that custom clients, especially ones written quickly to test a server, often skip the initialized notification because it does not require a response, and then everything else fails with confusing errors.

If you are debugging a server and seeing "method not allowed" or "not initialized" errors on tools/list, the first thing to check is whether your client sent the initialized notification after receiving the initialize response. The official Inspector handles this correctly. Hand-rolled test clients often do not.

Transports: The 30-Second Version

A transport is the wire your messages travel over. The MCP spec defines two standard transports.

stdio

The server is a subprocess. The client launches it with spawn, writes JSON-RPC messages to its stdin, reads responses from its stdout, and treats stderr as a log stream.

This is the dominant transport in 2026. Every Claude Code server config I have seen uses stdio. Every @yawlabs server runs over stdio when invoked by npx @yawlabs/foo-mcp.

The contract:

tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; const server = new McpServer({ name: "tailscale-mcp", version: "0.3.0" }); // register tools... const transport = new StdioServerTransport(); await server.connect(transport);

Pitfall: never console.log from a stdio server's main process. console.log writes to stdout. Use console.error for logs, which writes to stderr. I have shipped this bug twice and debugged it on other people's servers a dozen times. The symptom is the client showing "invalid JSON-RPC message" errors at startup and dying. The fix is one character.

Streamable HTTP

The server is an HTTP server. The client makes HTTP requests with JSON-RPC payloads. The server can hold the connection open as a Server-Sent Events stream to push notifications. A session ID -- carried in the Mcp-Session-Id header -- ties multi-message exchanges together.

tsimport { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; const app = express(); app.use(express.json()); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); app.all("/mcp", async (req, res) => { await transport.handleRequest(req, res, req.body); }); await server.connect(transport); app.listen(3000);

This is the transport for hosted servers, which is what mcp.hosting runs. The same protocol, the same handlers, the same registered tools. The only thing that changes is the wire.

The takeaway: design your server around the registered tools and the handlers. The transport choice is a deployment concern. You can ship the same server over stdio for local use and HTTP for hosted use, and most of the time it is exactly the same code.

Notifications and Dynamic State

The connection between client and server is bidirectional. After the handshake, the server can push notifications -- one-way messages -- to the client. The wire methods all carry the notifications/ prefix; the suffixes you will care about:

So tools/list_changed rides the wire as notifications/tools/list_changed, progress as notifications/progress, and so on. The code samples below use the full method names; the bullet labels above just drop the common prefix for readability.

The first three open the door to dynamic tool lists. Your server can register a tool, then later remove or change it, and notify the client. This is a real and useful feature in two cases:

  1. Auth-gated tools. Before the user authenticates, the server exposes only login. After authentication, it exposes the full toolset. @yawlabs/lemonsqueezy-mcp does something close to this -- it gates write tools behind a verified API key check.
  2. Context-dependent tools. The set of tools depends on which directory you opened, which repo you cloned into, which environment you are connected to. A filesystem-aware server might expose git_* tools only when the working directory is a git repo.

From the field: the wrong reason to use dynamic tool lists is "I have too many tools and want to declutter the model's view." The model handles 30-40 tools fine. Hiding tools behind state transitions to reduce visual clutter just means the model does not know they exist, which means it does not call them. If you have 80 tools and are tempted to hide half of them, the real fix is splitting your server into two servers, not hiding tools.

Progress notifications are a separate story. If a tool takes more than a few seconds, send progress.

tsserver.registerTool( "deploy_acl", { description: "Deploy the staged ACL to production.", inputSchema: {}, }, async (_args, extra) => { const { sendNotification } = extra; const progressToken = extra._meta?.progressToken; const notify = async (progress: number) => { if (progressToken === undefined) return; await sendNotification({ method: "notifications/progress", params: { progressToken, progress, total: 100 }, }); }; await notify(0); await tailscaleClient.validateAcl(); await notify(30); await tailscaleClient.applyAcl(); await notify(100); return { content: [{ type: "text", text: "ACL deployed successfully." }], }; } );

Pitfall: the progressToken is not yours to invent -- the client supplies it in the request's _meta.progressToken and you echo it back so the client can correlate progress events to the originating call. Hardcode a string and concurrent invocations will all fight over the same token, leaving the client unable to tell which deploy is at 30% and which is at 100%.

The client decides what to do with progress -- typically render a spinner with a percentage. The model does not see progress notifications directly. They are a UX feature, not a model-input feature.

Common Failure Modes by Layer

When something goes wrong with an MCP server, the symptom is usually generic: "the tool didn't work" or "Claude Code says the server crashed." Untangling that into an actual root cause is easier when you have a layered mental model. Here is the taxonomy I use, working from outermost to innermost.

Transport Errors

The wire itself is broken.

Protocol Violations

The wire works, but the conversation is malformed.

Schema Mismatches

The conversation is well-formed at the protocol level, but the contents do not match what the parties expect.

Handler Errors

The contract is fine. Your code is broken.

When something goes wrong, walk this list top to bottom. If the transport is healthy, move to protocol. If protocol is fine, look at schemas. If schemas pass, you are in handler land. The vast majority of bugs in production servers are in the bottom two layers, but the diagnostic instinct should always start at the top, because the symptoms there are loudest and the layers below them only matter if the connection is actually live.

Forward to Chapter 3

That is the conceptual map. Tools are 90% of what you ship. Resources earn their keep in narrow cases. Prompts almost never. The handshake auto-negotiates capabilities from what you register. Stdio and HTTP are the two transports and the same code runs over both. Notifications open the door to dynamic state, useful in narrow cases and tempting in too many. Failures stack in layers and the diagnostic order is transport, protocol, schema, handler.

In Chapter 3, we will build a real server end to end -- a small wrapper around an HTTP API, with three tools, structured outputs, error handling, annotations, and a test harness. By the end of that chapter you will have a working server, a config snippet you can paste into Claude Code, and a feel for the iteration loop that turns a bare idea into a tool the model wants to call. After that, Chapter 4 turns to auth and secrets -- the three credential doors a server has to choose from, OAuth 2.1 with PKCE for HTTP transports, the npmrc-class bug that keeps biting people, and what multi-tenancy actually demands of your handlers.


Hands-on

Before you build, connect. Pick a published MCP server -- one of the @yawlabs/* servers, the official @modelcontextprotocol/server-filesystem, or any other server you've been curious about -- and install it in both Claude Desktop and Claude Code. Then ask the model to use it for a real task. Watch the JSON-RPC traffic if your client surfaces it; otherwise just observe which tool the model picks, what arguments it passes, and how the result lands back in the chat.

If you want a remote-server experience too, point Claude Code at an HTTP MCP server (claude mcp add <name> --transport http <url>) and run the same exercise. The wire format is the same; only the transport changes. This is the usage-side counterpart to the architectural map this chapter just laid out.

Companion repo: the exercises/module-2/ directory has a guided walkthrough for connecting to remote MCP servers, self-hosting one with Docker on a free-tier host, and configuring bearer-token auth on both ends. The companion repo is public -- just clone it from https://github.com/YawLabs/mcp-in-production-companion.