Lesson 12 The TypeScript MCP SDK
Theme:
Lesson 12 · Slide 1

Lesson 12 — The TypeScript MCP SDK

Port L04's hello-world to TypeScript with the official SDK, Zod schemas, and a pino log line per call.

MCP Servers Module 5

Lesson 12 · Slide 2

What we're building

The smallest possible TS PKB server

One tool (echo), Zod-validated, structured-logged, stdio transport. Runnable under npm run dev in seconds.

The layout we keep extending

src/server.ts + tools.ts + schemas.ts + logger.ts. L13–L15 add files; these four stay.

"Same protocol shape as L04. Different language. Same logs."

Lesson 12 · Slide 3

The SDK's two-handler model

The TS SDK does not ship a decorator like FastMCP. You register two request handlers — ListToolsRequestSchema and CallToolRequestSchema — and dispatch inside CallTool by tool name.

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [/* ... */] }));

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

Trade-off vs FastMCP: more boilerplate, more control over every seam.

Lesson 12 · Slide 4

StdioServerTransport

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const transport = new StdioServerTransport();
await server.connect(transport);

Same wire protocol as Python's stdio; same Inspector compatibility; same Claude-Desktop compatibility. Transport is swappable — L13 swaps in HTTP+SSE without touching tools.ts.

Lesson 12 · Slide 5

Zod vs Pydantic — the same job

Pydantic (Python)

class EchoInput(BaseModel):
    text: str

Zod (TypeScript)

const EchoInput = z.object({
  text: z.string(),
});

Same role: validate at the runtime boundary, infer types statically. Zod just lives at the value level, not at the class level.

Lesson 12 · Slide 6

Why runtime validation matters in TS

Lesson 12 · Slide 7

z.infer<typeof Schema>

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

One definition produces both a runtime validator and a compile-time type. No drift.

Lesson 12 · Slide 8

pino and the child-logger pattern

export const logger = pino({ base: { service: "pkb" } });

export function toolLogger(tool: string) {
  return logger.child({ tool });
}

Every tool grabs a child logger that pre-stamps {tool: "..."}. The rest of the log shape (phase, dur_ms, type) matches the Python log_tool_call decorator from L10.

Lesson 12 · Slide 9

The tool function shape

export async function echoTool(rawArgs: unknown): Promise<string> {
  const log = toolLogger("echo");
  const t0 = performance.now();
  log.info({ phase: "start" });
  try {
    const args = EchoInput.parse(rawArgs);
    const result = args.text;
    log.info({ phase: "ok", dur_ms: +(performance.now()-t0).toFixed(1), text_len: args.text.length });
    return result;
  } catch (err) {
    log.warn({ phase: "err", dur_ms: +(performance.now()-t0).toFixed(1), type: (err as Error).name });
    throw err;
  }
}

Each tool repeats the start/ok/err pattern. We don't extract a decorator yet — async-decorator support in TS is uneven across runtimes. We extract a helper in L15.

Lesson 12 · Slide 10

The dispatcher in server.ts

The CallToolRequestSchema handler switches on req.params.name and returns { content: [{ type: "text", text }] }:

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

Convention: tool errors should set isError: true and put the message in content — do not throw. Same as the Python SDK.

Lesson 12 · Slide 11

tsx for dev, tsc for prod

npm run dev    # tsx src/server.ts  (runs TS source directly)
npm run build  # tsc                (emits dist/server.js)
npm start      # node dist/server.js

Dev loop is tsx — no separate compile step. Production runs tsc-compiled JS so the Docker image (L13) doesn't need TypeScript at runtime.

Lesson 12 · Slide 12

Connect to Inspector

npx @modelcontextprotocol/inspector node dist/server.js

Inspector boots, lists echo, you call it with {"text":"smoke"}, watch the log line in your terminal. Same workflow as mcp dev in Python — different harness, same loop.

Lesson 12 · Slide 13

What's in the lab

Lesson 12 · Slide 14

Recap & what's next

Coming up: Lesson 13 — Containerize this server with Docker, swap stdio for HTTP+SSE, and put Caddy in front with automatic TLS.
1 / ?