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:
npm run dev—tsx src/server.tsruns your TS source directly. Use this in the inner loop and against the Inspector. No precompile step, nodist/drift to worry about.npm run build—tsccompilessrc/**/*.tstodist/. Zero errors required. The Docker image in L13 only shipsdist/, notsrc/, so this is the gate.npm start—node dist/server.jsruns the compiled output. This is exactly what the production Docker image (L13) calls.
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
- Importing local modules without the
.jsextension under ESM. Build succeeds; runtime throws “module not found”. Always./tools.js, even from a.tsfile. throwing from a tool handler instead of returningisError: true. The client sees a generic protocol error, not your message, and debugging gets ten times harder.- Casting
req.params.argumentsto a typed shape without Zod-parsing first. The cast is a lie at runtime; the handler crashes on the first real-world bad input. - Logging the whole
req.params.argumentsobject. Same redaction rule as Python L10: log lengths, types, names, never values. A user prompt can contain anything. - Forgetting
"type": "module"inpackage.json. You get crypticrequire()/importerrors that look unrelated to the missing field. Set it before you write the firstimport.
References
@modelcontextprotocol/sdkon GitHub — the official SDK we depend on; README coversServer, transports, and request-handler registration.- Zod documentation —
z.object,z.string().min().max(),z.infer,.parsevs.safeParse. - pino documentation — root logger, child
loggers, log levels, and the
pino-prettydev formatter. zod-to-json-schema— mentioned but not used in L12; useful when your tool count grows past the hand-authored threshold.- Node.js ESM resolver docs — the authoritative reference on why local imports need explicit extensions.
- PKB TypeScript sample-server tree — the working code you walk through in the lab; the same tree grows through Lessons 13–15.