Anatomy of an MCP Server
Series
MCP Deep DiveMost developers encounter MCP through a client library, call server.tool(...), and move on without ever understanding what happens on the wire. That works until something breaks — a capability mismatch, a silent shutdown, a transport failure at 3 a.m. Knowing the full anatomy of an MCP server turns debugging from guesswork into targeted inspection.
This post walks every phase of an MCP server's life: initialization, capability negotiation, request handling, and shutdown.
The Four Phases of an MCP Server
An MCP server is not just an HTTP handler. It has a defined lifecycle with four distinct phases, each with protocol-level semantics:
Skipping any of these phases produces undefined behaviour. A server that accepts tool calls before the client sends notifications/initialized is violating the spec, and a client that does the same will encounter servers that rightfully reject the request.
Phase 1 — Initialization and Capability Negotiation
The handshake starts with the client sending an initialize request. This is not a greeting — it is a negotiation. Both sides declare what they support, and the session is governed by the intersection.
// Client sends:
{
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {
roots: { listChanged: true },
sampling: {}
},
clientInfo: { name: "claude-desktop", version: "0.9.0" }
}
}
// Server responds:
{
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: "2024-11-05",
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
prompts: {}
},
serverInfo: { name: "my-crm-server", version: "1.2.0" }
}
}The capabilities object is the critical field. If the client does not declare sampling, the server must not initiate sampling requests. If the server does not declare resources.subscribe, the client must not subscribe to resource changes. Capability violations should produce method-not-found errors, not silent failures.
Phase 2 — Server Internals: What You Are Actually Registering
Inside the server, you register handlers for methods. The MCP TypeScript SDK makes this feel like routing:
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: "crm-server",
version: "1.2.0"
});
// Register a tool
server.tool(
"get_contact",
"Fetch a CRM contact by email address",
{
email: z.string().email().describe("The contact's email address")
},
async ({ email }) => {
const contact = await crmClient.contacts.findByEmail(email);
if (!contact) {
return { content: [{ type: "text", text: `No contact found for ${email}` }], isError: true };
}
return {
content: [{ type: "text", text: JSON.stringify(contact, null, 2) }]
};
}
);
// Register a resource
server.resource(
"crm://contacts/recent",
"Recent CRM contacts",
async (uri) => ({
contents: [{ uri: uri.href, mimeType: "application/json", text: await crmClient.contacts.recent() }]
})
);
const transport = new StdioServerTransport();
await server.connect(transport);Every registered tool and resource is surfaced during the capabilities phase. The SDK handles the tools/list and resources/list methods automatically based on what you register.
Phase 3 — The Request/Response Loop
After initialization, the session enters the request loop. Each call is a JSON-RPC request with a unique id. The server must respond with that exact id — the client uses it to match async responses.
One detail that bites teams: errors at the protocol level are different from errors at the tool level. A protocol error (invalid params, method not found) uses JSON-RPC error codes. A tool-level error — "the user was not found in the CRM" — should return a successful JSON-RPC response with isError: true in the content. Conflating these two error domains produces clients that crash on legitimate tool failures.
Phase 4 — Shutdown and Teardown
MCP defines an explicit shutdown sequence. The client signals intent to close; the server finishes in-flight requests and releases resources before confirming. On stdio transport, this happens when the transport stream closes. On HTTP/SSE transport, there is an explicit shutdown method followed by transport close.
// Graceful shutdown hook — always implement this
process.on("SIGTERM", async () => {
await server.close();
await dbPool.end();
process.exit(0);
});
process.on("SIGINT", async () => {
await server.close();
process.exit(0);
});Servers that do not implement graceful shutdown leave connection pools open, orphan background jobs, and produce confusing errors on the next startup. This is especially problematic with stdio servers that are spawned fresh per session — the cleanup window is small.
The Full Structure at a Glance
stdio / SSE / HTTP] P[Protocol Handler
JSON-RPC dispatch] C[Capability Registry
tools, resources, prompts] H[Business Logic Handlers] E[Error Normalizer] end Client -->|wire bytes| T T --> P P --> C C --> H H --> E E --> P P -->|wire bytes| Client style T fill:#7b1fa2,color:#fff style P fill:#1565c0,color:#fff style C fill:#00695c,color:#fff style H fill:#e65100,color:#fff style E fill:#b71c1c,color:#fff
The transport layer handles byte I/O and framing. The protocol handler does JSON-RPC parsing, id matching, and method dispatch. The capability registry answers tools/list, resources/list, and prompts/list. Handlers contain your actual logic. The error normalizer ensures protocol errors and tool errors are represented correctly on the wire.
What Goes Wrong Without Understanding This
The most common production issues I've seen from teams that skip this understanding:
- Capability mismatch silent failures — server declares
tools: {}but client expectstools: { listChanged: true }, so dynamic tool registration is never picked up. - No error domain separation — throwing exceptions from tool handlers instead of returning
isError: truecauses clients to treat "record not found" as a protocol fault. - No graceful shutdown — stdio servers that die mid-stream leave the client waiting for a response that will never arrive.
- Missing initialized notification — servers that start accepting requests before the client sends
notifications/initializedviolate the spec and fail with strict clients.
Understanding the anatomy does not mean implementing it from scratch. It means knowing which layer broke when something fails.
Key Takeaways
- An MCP server has four distinct lifecycle phases: initialization, capability negotiation, request/response loop, and graceful shutdown — skipping any phase produces undefined behaviour.
- Capability negotiation is a two-way declaration; both client and server must honor only the intersection of what each side announced.
- Protocol-level errors (invalid params, unknown method) and tool-level errors (business logic failures) are represented differently on the wire — conflating them breaks clients.
- The SDK abstracts the protocol layer, but you must still wire graceful shutdown handlers or risk orphaned resources on teardown.
- Tools, resources, and prompts are registered declaratively; the SDK surfaces them via
listmethods automatically during the capability phase. - Knowing the full anatomy converts 3 a.m. debugging from "why is the client stuck?" to "which layer in the server's stack failed?"
Series
MCP Deep Dive