Why use an SDK

You have now seen every message MCP sends. You could write them by hand, and in this course's first lab we did. For anything real, an SDK does the plumbing so you write only tool logic.

By the end of this lesson you can

  • trace one request through the layers an SDK handles before your code runs;
  • compare a raw server with the SDK version of the same server;
  • list what the TypeScript SDK v2 does for you, and what it leaves to you;
  • say when writing raw is still the right call.

What happens before your code runs

An SDK packages the specification and does the plumbing. Follow one tools/call through a server built with it: almost every stop is something you would otherwise write and keep up to date with the spec.

One tools/call, through the SDK

Clientstdio or HTTP the SDK transportheadersOrigin JSON-RPCframingid eramodern orlegacy schemazodvalidation Your handlertool logic Mcp-Method and Mcp-Name match the body? Origin allowed? Body within limits? parse one message, remember its id, shape errors per JSON-RPC 2.0 _meta version on the request → modern; initialize → legacy shim; else -32022 arguments checked against the same schema that tools/list advertised only this part is yours: return content, or inputRequired() result wrapped with the same id, sent as JSON or a short SSE stream
  1. The transport layer checks what the spec demands of HTTP: the Mcp-* headers agree with the body (else 400 and -32020), the Origin is allowed, the body is within limits. On stdio it reads one line.
  2. JSON-RPC: parse the message, keep its id so the reply can be correlated, and shape any error the standard way.
  3. Era routing: a request carrying _meta with a protocol version is modern; initialize opens the legacy path; an unknown version gets -32022 with the supported list.
  4. Validation: the arguments are checked against the zod schema you registered, the same one turned into JSON Schema for tools/list.
  5. Your handler runs. This is the only part you wrote: your business logic, returning content, or inputRequired() if it needs the user.
  6. The SDK wraps the result with the request's id and sends it back as one JSON object or a short SSE stream.

Raw versus SDK, side by side

The raw server in mcp/01-lifecycle-demo/server.mjs is about 120 lines and hand-writes a thin slice of the protocol, stdio only. The SDK server in mcp/02-server/src/ does more, on both transports and both eras, in less code.

Raw, about 120 lines

// framing, parsing, routing: yours
rl.on('line', (line) => handle(JSON.parse(line)));

function handle({ id, method, params = {} }) {
  if (method === 'initialize') return ok(id, {…});
  const v = params._meta?.[V];
  if (v && v !== MODERN)
    return err(id, -32022, 'Unsupported…', {…});
  switch (method) {
    case 'server/discover': return ok(id, {…});
    case 'tools/list':     return ok(id, { tools });
    case 'tools/call':
      // your own HMAC, your own input_required …
  }
}
// still missing: argument validation, header
// checks, HTTP, SSE, cancellation, progress,
// subscriptions, the legacy shim …

SDK v2, the same server

import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { toNodeHandler } from '@modelcontextprotocol/node';

function createServer() {
  const server = new McpServer({ name: 'demo', version: '0.1.0' });
  server.registerTool('greet',
    { description: 'Greet someone',
      inputSchema: z.object({ name: z.string() }) },
    async ({ name }) =>
      ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }));
  return server;
}

// local
serveStdio(createServer);
// remote, stateless, both eras: factory called per request
const mcp = toNodeHandler(createMcpHandler(createServer));

Facts for the TypeScript SDK v2 as used in the labs: @modelcontextprotocol/server 2.3.1 implements revision 2026-07-28; createMcpHandler(factory) calls your factory per HTTP request; serveStdio(factory) for local; inputRequired(), acceptedContent() and createRequestStateCodec({ key, bind, ttlSeconds }) for MRTR; toNodeHandler from @modelcontextprotocol/node adapts it to Node's http server.

What it does, and what stays yours

What it does for you

  • Framing, id correlation, error shapes
  • server/discover built from what you registered
  • Schemas from zod: JSON Schema for the list, validation for the call
  • Era routing: modern and legacy from one factory, with a shim for input required
  • Header checks, Origin checks, JSON or SSE replies, cancellation, body limits
  • inputRequired() builders and a signed requestState codec
  • OAuth helpers: bearer checks, protected resource metadata

What stays yours

  • Validating tokens; it only passes authInfo through
  • Your business logic and data access
  • Whether a tool description can be trusted
  • Protecting requestState, unless you use the codec
  • A shared event bus when subscriptions span replicas
  • Following the spec's churn: v1 to v2 was a breaking change

Key idea

Write raw to learn or to debug; use the SDK for anything real. Keep the raw client, though: it is how these labs checked the SDK server, by printing every line on the wire.

Common misconception

"The SDK handles auth." It handles the protocol around auth: reading the bearer header, serving protected resource metadata, passing authInfo to your tools. Deciding whether a token is genuine, unexpired and meant for you is still your code (or your gateway's).

SDK or you?

Who is responsible for each of these in a server built on SDK v2?

  • Returning error -32020 when Mcp-Name disagrees with the body. Header checks are transport plumbing.
  • Checking that a bearer token was issued for this server's audience. The SDK passes authInfo through; validating it is yours.
  • Matching a response to request id 7. id correlation is the first thing any JSON-RPC library does.
  • Deciding a "deploy" tool must ask the user before acting. That is business logic. The SDK gives you inputRequired() to express it.
  • Answering a legacy client's initialize. Era routing and the legacy shim come with createMcpHandler.
  • Fanning out list_changed to listen streams held by other replicas. Cross-replica subscriptions need a shared event bus you provide.

Quick check

With createMcpHandler(createServer), when is createServer called?

  • Once, at start-up
  • Once per user session
  • Once per HTTP request
That is what makes the server stateless: nothing in the factory survives between requests, so any replica can answer.

Your tool registered a zod schema. Where is it used?

  • Only to validate arguments on tools/call
  • As JSON Schema in tools/list, and to validate arguments on tools/call
  • Only in TypeScript types; nothing at run time
One schema, two jobs: the model reads it in the list, and the SDK rejects bad arguments before your handler runs.

When is writing a raw server the better choice?

  • For production, to avoid dependencies
  • To learn the wire, or to debug what an SDK is actually sending
  • Whenever you only need stdio
Raw code shows every byte. It also misses most of the spec, and must chase every revision by hand.

Recap