Lesson 12 The TypeScript MCP SDK
Theme:

The TypeScript MCP SDK

Lesson 12 · MCP Servers · ~10 min read

The TS SDK in one paragraph

The official @modelcontextprotocol/sdk package exposes a low-level Server class — similar in spirit to Python’s bare Server, not FastMCP. You construct a Server, declare which capabilities it advertises ({ tools: {} }), then register request handlers for each MCP request type using setRequestHandler(SCHEMA, async (req) => …). Two handlers cover most tool servers: ListToolsRequestSchema (advertise) and CallToolRequestSchema (execute). Transports plug in via await server.connect(transport). There is no FastMCP-style decorator layer in the TS world yet — you write the dispatch yourself. That costs roughly ten lines of boilerplate per server but exposes every seam, which is what we want for the production half of the course.

Project layout

PKB-TS lives in mcp-servers/sample-server/typescript/. The shape we land in Lesson 12 is small enough to read in one sitting and large enough to keep growing through Lessons 13–15 without reshuffling:

package.json     # "type":"module", scripts: build/dev/start
tsconfig.json    # strict, ES2022, outDir: dist/
src/
  server.ts      # Server + transport + handler registration
  tools.ts       # one async function per tool
  schemas.ts     # Zod schemas, re-exported as types via z.infer
  logger.ts      # pino root logger + toolLogger helper

Lesson 13 adds Dockerfile, docker-compose.yml, and Caddyfile at the project root so the same server runs HTTP+SSE behind automatic TLS. Lessons 14 and 15 add src/auth.ts, src/store.ts, src/rateLimit.ts, src/audit.ts, src/sanitize.ts, scripts/fuzz.ts, and a RUNBOOK.md — each in its own file so commits stay reviewable and so the test surface area per file stays small.

ESM-only and the .js extension on local imports

The SDK is published as ESM only. Your package.json needs "type": "module". Your tsconfig.json needs "module": "ES2022" (or "NodeNext"). The big surprise: under ESM you must import local modules with the output extension, even from .ts source:

import { echoTool } from "./tools.js";   // not ./tools, not ./tools.ts

tsc does not rewrite extensions. Node’s ESM resolver does not auto-resolve ./tools to ./tools.js. Your .ts source imports ./tools.js, which doesn’t exist on disk until tsc compiles — but tsx and modern editors handle the mismatch transparently, so the dev loop never trips on it. Get the extension wrong and you’ll see Cannot find module ‘./tools’ imported from …/server.js at runtime, even though npm run build reported zero errors. Pin the pattern early: always .js.

Zod and runtime validation

TypeScript’s type system is gone at runtime. A handler that receives req.params.arguments sees unknown — and even if you cast it to EchoInput, the cast is a lie until something validates the shape. Zod gives you both a runtime check and a TS type from one declaration:

export const EchoInput = z.object({
  text: z.string().min(1).max(10_000),
});
export type EchoInput = z.infer<typeof EchoInput>;

Use EchoInput.parse(rawArgs) (throws on invalid) or .safeParse(rawArgs) (returns { success, data | error }) at the boundary. The thrown error carries a structured .issues array; log the type (ZodError) and a redacted summary of which field failed, never the value itself. The value might be a user prompt, a secret, or a prompt-injection attempt — none of which belong in your logs.

One subtlety: the schema advertised in ListTools is JSON Schema, not Zod. You can either (a) hand-author the JSON Schema once for the wire format and Zod again for runtime — the L12 sample does this for clarity — or (b) use zod-to-json-schema to derive one from the other. For three tools, hand-authored is fine and the wire shape stays grep-able. For thirty tools, derive: drift between the advertised schema and the runtime validator is a real production bug class.

pino and structured logging

pino is the de-facto Node.js structured logger. It emits one JSON object per line, runs fast (≥ 50 k lines/sec on a modern laptop), and supports child loggers with bound context. The PKB pattern from Lesson 12 onward:

export const logger = pino({ base: { service: "pkb" } });
export function toolLogger(tool: string) { return logger.child({ tool }); }

Every tool grabs a toolLogger("echo") at function top, and every log line is automatically tagged {service: "pkb", tool: "echo"}. The per-tool log line shape mirrors Python L10:

{ "service": "pkb", "tool": "echo", "phase": "start" }
{ "service": "pkb", "tool": "echo", "phase": "ok", "dur_ms": 0.4, "text_len": 5 }

For the dev loop, pipe through pino-pretty (npm run dev | npx pino-pretty) to get human-readable colour. Production keeps the JSON so log shippers (Loki, Datadog, CloudWatch, OpenTelemetry collectors) can ingest it without a regex layer — we cover the shipper side in Lesson 15.

The two-handler dispatcher

ListToolsRequestSchema’s handler returns { tools: […] } — an array of { name, description, inputSchema } triples. The handler is sync-or-async; cache the list at module load if you have many tools, otherwise let it rebuild on each call (cost is microseconds and the code stays simple).

CallToolRequestSchema’s handler receives { name, arguments } and returns { content: [{ type: "text", text }] } for success or { isError: true, content: [{ type: "text", text }] } for failure. The most important convention to internalise: never throw from a tool handler. If you throw, the SDK marshals a generic protocol-level error and the client loses your useful error message. Return isError: true with a human-readable message instead:

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  try {
    if (name === "echo") {
      return { content: [{ type: "text", text: await echoTool(args) }] };
    }
    return { isError: true, content: [{ type: "text", text: `unknown tool: ${name}` }] };
  } catch (err) {
    return { isError: true, content: [{ type: "text", text: (err as Error).message }] };
  }
});

This is the dispatcher shape we extend in L13 (add HTTP transport), L14 (add bearer-auth middleware in front), and L15 (wrap each call in audit logging + sanitisation). Get it right once here; the next three lessons only add layers around it.

Building and running

Three commands cover the dev / build / production loop:

Connect the Inspector to the dev process for an interactive smoke test:

npx @modelcontextprotocol/inspector npx tsx src/server.ts

Or connect to the built artefact for a “is the production path wired” check:

npx @modelcontextprotocol/inspector node dist/server.js

Both invocations should list echo and round-trip {"text":"hello"}. If only one of them works, you have a build-vs-source drift problem — usually a missing .js extension on a local import.

Common pitfalls

Five pitfalls before you ship a TS MCP server

  1. Importing local modules without the .js extension under ESM. Build succeeds; runtime throws “module not found”. Always ./tools.js, even from a .ts file.
  2. throwing from a tool handler instead of returning isError: true. The client sees a generic protocol error, not your message, and debugging gets ten times harder.
  3. Casting req.params.arguments to a typed shape without Zod-parsing first. The cast is a lie at runtime; the handler crashes on the first real-world bad input.
  4. Logging the whole req.params.arguments object. Same redaction rule as Python L10: log lengths, types, names, never values. A user prompt can contain anything.
  5. Forgetting "type": "module" in package.json. You get cryptic require()/import errors that look unrelated to the missing field. Set it before you write the first import.

References