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
- L11 lab complete. Node 20+ and Docker are installed and
working (
node --version,docker run --rm hello-worldboth pass). The TS skeleton undermcp-servers/sample-server/typescript/is in place —package.json,tsconfig.json, and the throwingsrc/server.tsstub. If any of that is shaky, fix it before continuing; this lab adds on top of it. - Working directory. Every command below runs from
mcp-servers/sample-server/typescript/unless noted. Open a shell there now and keep it open for the whole lab. - MCP Inspector available. The Inspector ships as an npx package
(
@modelcontextprotocol/inspector) — no install needed, but the first invocation downloads it, so confirm you have network access.
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:
- Root logger has
service: "pkb". Every line emitted anywhere in the process is tagged with that field, so a downstream log shipper (Loki, Datadog, CloudWatch) can route PKB traffic without parsing the message. toolLogger(name)returns a child logger that pre-stamps{tool: name}. Every line from a given tool is automatically scoped — you never have to remember to add the field by hand, and a refactor cannot drop it.- Level is env-configurable. Run
LOG_LEVEL=debug npm run devto crank up verbosity without touching code; defaults toinfo. - The
formatters.leveltweak writes the level as a string ("info") instead of pino's default numeric code (30). Human-readable JSON without losing structure.
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:
EchoInputis both a value and a type. Theconstbinding is a Zod schema you can call.parse()on; thetype EchoInput = z.infer<typeof EchoInput>line lifts the same shape into the type system. One definition, two consumers, zero drift. Contrast Python's Pydantic where the class itself is both — in TS, Zod keeps the runtime validator at the value level..min(1).max(10_000)catches two failure modes at the boundary. Empty strings (a common bug from a client that forgot to populate the field) reject early with a clear message. Pathological inputs (a 1 MB pasted document, an accidental log dump) are rejected before they reach the tool body, the log line, or anything downstream. The10_000cap is deliberately small forecho; production tools pick their cap from the worst legitimate use-case.
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:
- Note the
.jsextension on the local imports. The source is TypeScript, but ESM resolution at runtime looks for the compiled output.tscdoes not rewrite extensions. Always./schemas.js, never./schemasor./schemas.ts. - Child logger at the top. One line, and every subsequent
log.info/log.warninside this function is automatically tagged{tool: "echo"}. t0 = performance.now()— high-resolution monotonic timer in milliseconds, the same primitive the Pythonlog_tool_calldecorator uses viatime.perf_counter(). Subtracting at the end givesdur_ms.phase: "start"log line emitted before the parse. If the parse throws on bad input, you still see the start line in your terminal — useful for spotting "call arrived, validation failed" as distinct from "call never arrived".EchoInput.parse(rawArgs)is the only line that promotes theunknownto a real, typed value. After this line,args.textis astringknown to be 1–10 000 chars long. Before it, every property access was a lie.phase: "ok"logstext_len, nevertext. The whole point of the decorator-equivalent pattern is the boundary: you log the shape of what happened, not the content.echohappens to return the input verbatim, but the log line stays content-free.phase: "err"logs the error type, never the value. AZodErrorwith a 12 000-char failing payload becomes the single fieldtype: "ZodError"— the payload never enters the log. Re-throw so the SDK can format the protocol-level error for the client.- The repeated start/ok/err shape is intentional boilerplate. L15 extracts it into a small helper. For L12–L14 we keep it inline so the seams are visible.
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:
- The
Serverconstructor.{ name: "pkb", version: "0.2.0" }is what clients see in the handshake (the version bump from0.1.0to0.2.0tracks "first real tool").{ capabilities: { tools: {} } }advertises that this server has tools — without it, an Inspector will not even attempt to callListTools. ListToolsRequestSchemahandler. Returns a singleechoentry whoseinputSchemais hand-authored JSON Schema (not auto-derived from Zod). Two reasons: clarity (you can read the wire schema inline) and pedagogy (you see explicitly what the client sees). In a 30-tool server you would derive these viazod-to-json-schema; for one tool the duplication is the right trade.CallToolRequestSchemahandler. Pullsnameandargumentsout ofreq.paramsand dispatches by name. Forechoit awaits the tool function and wraps the string in{ content: [{ type: "text", text }] }— the standard MCP success shape. For an unknown name it returns{ isError: true, content: [...] }rather than throwing — throwing would surface a generic protocol-level error and the client would lose the friendly message.main(). Constructs the stdio transport, awaitsserver.connect(transport), then logsphase: "ready", transport: "stdio". The order matters: log after connect succeeds so the line means "server is actually listening", not "server got as far as creating a transport object".- The
main().catch(...)tail. Any unhandled rejection frommain()hitslogger.fataland exits the process with code 1. A non-zero exit lets Docker (L13) and any process supervisor mark the container as crashed so it can be restarted with policy.
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:
- Tool listing. The Tools tab should show one entry,
echo, with the description "Return the input text unchanged..." and a JSON-Schema-rendered input field fortext. If the list is empty, your server is connected butListToolsis not returning the array — check the handler. - Happy-path call. Call
echowith{"text":"smoke"}. The result panel should show aTextContentwithtext: "smoke". In the terminal where you launched the Inspector you should see two JSON log lines:phase=startandphase=ok dur_ms=<tiny> text_len=5. The literal string"smoke"must not appear in either log line — onlytext_len: 5. - Bad-input path. Call
echowith{}(notextfield). The Inspector should display an error result; the terminal log line should bephase=err type=ZodError. The actualZodErrormessage string is not logged — only its type. - Unknown-tool path (optional). Most Inspectors do not let you
invoke an unregistered tool, but if yours has a raw JSON-RPC tab, send a
tools/callwithname: "nope". You should get back{ isError: true, content: [{ type: "text", text: "unknown tool: nope" }] }— the dispatcher's fall-through branch.
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
- A running TypeScript PKB server that answers MCP
echocalls and emits onephase=start+ onephase=ok|errJSON log line per invocation — the same shape as the Python L10 decorator. - Reflexive Zod-at-the-boundary discipline. Every tool you add
in L13–L15 will start with
SomeInput.parse(rawArgs)as the first line inside thetryblock, never a typed-cast. - The four-file layout in your fingertips.
server.ts(transport + dispatch),tools.ts(one function per tool),schemas.ts(Zod + JSON Schema), andlogger.ts(root + child loggers). L13–L15 add files; none of these four change shape. - Inspector round-trip muscle memory.
npx @modelcontextprotocol/inspector node dist/server.jsis the smoke test you reach for any time you touch the TS server through the rest of Module 5.
Troubleshooting
Common stumbles
- "
Cannot find module './tools' imported from .../server.js". You wrote./tools(or./tools.ts) instead of./tools.jsin an import. Fix the extension on the local import,npm run buildagain, and re-run. This is the single most common TS-MCP bug and it always looks scarier than it is. - Inspector shows "
unknown tool: echo". YourCallTooldispatch checks the wrong string — capitalisation and dashes matter. Compare the literal inif (name === "echo")with the literal in theListToolsentry; they must match byte-for-byte. - Zod throws
Requiredontextwhen you sent one. Your request shape was{ "args": { "text": "hi" } }(FastMCP-style nested) instead of{ "text": "hi" }. The TS SDK passesreq.params.argumentsdirectly to your handler — noargswrapper. Drop the outer key. - Logs print English sentences instead of JSON. Someone
called
log.info("some text")instead oflog.info({ phase: "..." }).pinoexpects an object first, then an optional message string. The object form is what makes the output structured — the string form falls back to a flat message. npm run devexits silently.tsxtreats an error inmain()as fatal and may exit before thelogger.fatalline lands on stderr. Run withLOG_LEVEL=debug npm run devto flush more context, and checkmain().catch(...)is still wired at the bottom ofsrc/server.ts.tscreports "Cannot find module@modelcontextprotocol/sdk/server/index.js". You rannpm installwith the wrong working directory, or the install half-finished. Deletenode_modules/andpackage-lock.json, then re-runnpm installfrom insidemcp-servers/sample-server/typescript/.- The literal payload appears in a log line.
Stop. Open
src/tools.tsand check whether someone added{ text: args.text }to alog.infocall by accident. The contract for the PKB log shape is lengths and types, never values. Same rule as the Python L10 decorator. Restore thetext_len: args.text.lengthform and re-test.