Port L04's hello-world to TypeScript with the official SDK, Zod schemas, and a pino log line per call.
MCP Servers Module 5
One tool (echo), Zod-validated, structured-logged, stdio transport. Runnable under npm run dev in seconds.
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."
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.
StdioServerTransportimport { 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.
class EchoInput(BaseModel):
text: str
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.
unknown until something validates it.{} and crashes at the first property access.ListTools.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.
pino and the child-logger patternexport 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.
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.
server.tsThe 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.
tsx for dev, tsc for prodnpm 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.
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.
src/server.ts, tools.ts, schemas.ts, logger.ts.npm run build — zero errors.echo with {"text":"smoke"}.pino = the TS PKB foundation.start/ok/err log pattern survives the port intact.