Lesson 12 The TypeScript MCP SDK
Theme:

Lab 12 — Port echo to TypeScript, log it, smoke-test in Inspector

Lesson 12 · MCP Servers · ~45 min, code change committed under mcp-servers/sample-server/typescript/

This lab is the first hands-on port of the Python L04 echo tool to TypeScript. The code already exists in mcp-servers/sample-server/typescript/: four files in src/ (server.ts, tools.ts, schemas.ts, logger.ts), plus two new dependencies in package.json (zod and pino). Your job is not to type it from scratch — it is to read each file in order, understand why each line is there, build it, and round-trip a call through the MCP Inspector. By the end you have a TS server that responds to echo and emits the same start/ok/err log shape as the Python L10 decorator.

Prereqs

Step 1 — Install Zod and pino

Two new runtime dependencies and one dev dependency. From inside mcp-servers/sample-server/typescript/:

npm install zod pino
npm install -D pino-pretty

After this completes, open package.json and confirm the dependencies block now contains both packages alongside @modelcontextprotocol/sdk:

"dependencies": {
  "@modelcontextprotocol/sdk": "^1.0.0",
  "zod": "^3.23.0",
  "pino": "^9.0.0"
}

pino-pretty lives under devDependencies — it is only used in the dev loop to make JSON logs human-readable in a terminal; the production Docker image (L13) does not need it. The package-lock.json will also update; leave it as-is.

Step 2 — Read src/logger.ts

Open src/logger.ts. It is intentionally tiny:

import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  base: { service: "pkb" },
  formatters: {
    level: (label) => ({ level: label }),
  },
  // Tool-call child loggers add { tool: "<name>" } context.
});

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

What to notice:

Step 3 — Read src/schemas.ts

Open src/schemas.ts:

import { z } from "zod";

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

// Future tools (added in L14, L15) will register their schemas here.

Two ideas hide in those four lines:

Step 4 — Read src/tools.ts

Open src/tools.ts:

import { EchoInput } from "./schemas.js";
import { toolLogger } from "./logger.js";

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;
  }
}

Walk through this from top to bottom:

Step 5 — Read src/server.ts

Open src/server.ts. The throwing stub from L11 is gone; this is the new wired entry point:

// PKB MCP server entry point (TypeScript).
// Lesson 12 wires the first tool (`echo`) over stdio.
// Lesson 13 adds HTTP+SSE. Lesson 14 adds multi-tenant.
// Lesson 15 adds production hardening.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

import { echoTool } from "./tools.js";
import { logger } from "./logger.js";

const server = new Server(
  { name: "pkb", version: "0.2.0" },
  { capabilities: { tools: {} } },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "echo",
      description: "Return the input text unchanged. Useful for confirming the server is reachable.",
      inputSchema: {
        type: "object",
        properties: { text: { type: "string", minLength: 1, maxLength: 10000 } },
        required: ["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}` }],
  };
});

async function main(): Promise<void> {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  logger.info({ phase: "ready", transport: "stdio" });
}

main().catch((err) => {
  logger.fatal({ err: String(err) }, "server crashed");
  process.exit(1);
});

export { server };

Read it in five passes:

Step 6 — Build and run

Compile the TS source and confirm the dist artifacts:

npm run build      # tsc with zero errors
ls dist/           # confirm server.js, tools.js, schemas.js, logger.js

All four .js files should be present. If any are missing, you have a stale tsconfig.json — check that "include": ["src"] is still set.

Quick "does the process start" smoke test:

echo '' | node dist/server.js & sleep 1 && kill %1 || true

Expected: a single JSON line on stderr, {"level":"info","service":"pkb","phase":"ready","transport":"stdio"}, then the background job is killed. (The echo '' | bit gives the process an empty stdin so the stdio transport's read loop has something to attach to; the || true swallows the non-zero kill exit so your shell stays happy.)

Step 7 — Connect to Inspector

This is the real round-trip. From mcp-servers/sample-server/typescript/:

npx @modelcontextprotocol/inspector node dist/server.js

The Inspector launches in your browser (it prints a local URL). In the UI:

Step 8 — Optional: dev loop with pino-pretty

In a fresh terminal (still inside mcp-servers/sample-server/typescript/), run the server through tsx and pipe the logs through pino-pretty:

npm run dev | npx pino-pretty

Re-run the smoke calls in Inspector. You should see the same lines, but human-readable and colour-coded — field labels in dim grey, the values inline, the level in colour. The underlying JSON shape is identical; pino-pretty is just a transformer for the dev loop. Production keeps the raw JSON so log shippers parse it without a reverse-engineering pass.

What you should leave with

Troubleshooting

Common stumbles