Lesson 12 The TypeScript MCP SDK
Theme:

Solutions — Lesson 12

Reference answers for Lesson 12's three homework tasks · second tool (now), oversize-input rejection, pino-pretty log samples

Each solution below shows the worked answer for one homework task, followed by a "common mistake" callout that captures the failure modes we saw in earlier drafts. Your code shape should match; your timestamps, durations, and exact byte counts will obviously differ from the samples shown here. Compare against the self-check criteria from the homework before you call a task done.

Solution to Task 1 — Add a now tool

The new tool mirrors echo's structure: a Zod schema in schemas.ts, an async function in tools.ts wrapped in the start / ok / err log shape, plus two register-points in server.ts (the ListTools advert and the CallTool dispatch arm).

// src/schemas.ts — add at bottom:
export const NowInput = z.object({}).strict();
export type NowInput = z.infer<typeof NowInput>;

// src/tools.ts — add at bottom:
export async function nowTool(rawArgs: unknown): Promise<string> {
  const log = toolLogger("now");
  const t0 = performance.now();
  log.info({ phase: "start" });
  try {
    NowInput.parse(rawArgs);
    const result = new Date().toISOString();
    log.info({ phase: "ok", dur_ms: +(performance.now() - t0).toFixed(1) });
    return result;
  } catch (err) {
    log.warn({ phase: "err", dur_ms: +(performance.now() - t0).toFixed(1), type: (err as Error).name });
    throw err;
  }
}

// src/server.ts — add to ListTools:
{
  name: "now",
  description: "Return the current UTC time in ISO-8601 format.",
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
}
// and to CallTool dispatch:
if (name === "now") return { content: [{ type: "text", text: await nowTool(args) }] };

Reading this solution:

Common mistake

Forgetting .strict() on the Zod object so callers can pass {"foo": "bar"} and the schema silently accepts it. .strict() rejects unknown keys at the boundary, which matches the empty additionalProperties: false declared in the JSON Schema. The mismatch (loose Zod + strict JSON Schema) is invisible until a real client sends extra keys — at which point only one side complains and your audit log gets noisier than your dispatcher actually is.

Solution to Task 2 — Reject oversize input

The 10 000-char cap on EchoInput.text is what makes Zod refuse the 12 000-char payload. The expected error in the Inspector UI is the standard Zod message:

String must contain at most 10000 character(s)

And the matching log line on your terminal:

{"level":"warn","time":1716639611000,"service":"pkb","tool":"echo","phase":"err","dur_ms":0.6,"type":"ZodError"}

Two properties to confirm:

Sample paragraph for the "log the type, not the value" prompt:

Logging the value of a failing input means a 12 000-char payload — which might be a user's pasted document, a code snippet with credentials, or an attempted prompt injection — ends up in your log file. Once it is there it propagates: log rotation, log shipping to a centralised store, search indexes, backups. Logging only the type of failure (ZodError) plus the field name keeps the audit trail useful for debugging the category of failure (validation vs auth vs runtime) without ever recording the offending data. The boundary is where you have the safest information to log; cross it without redaction and every downstream system inherits the leak.

Reading the paragraph:

Common mistake

Including a "truncated" payload in the log "just for context". Truncation does not remove secrets — a 200-char truncation of a 12 000-char OAuth-bearer paste is still 200 chars of bearer token. Either log only the shape (type=ZodError text_len=12000) or log nothing about the value. The same rule applies to "redacted-but-not- really" patterns like keeping the first / last 4 chars of a token: a leaked prefix is a leaked token.

Solution to Task 3 — pino-pretty before/after samples

Expected first few lines of samples-raw.log (one ready, then echo start/ok pairs):

{"level":"info","time":1716639600000,"service":"pkb","phase":"ready","transport":"stdio"}
{"level":"info","time":1716639610000,"service":"pkb","tool":"echo","phase":"start"}
{"level":"info","time":1716639610001,"service":"pkb","tool":"echo","phase":"ok","dur_ms":0.4,"text_len":5}
{"level":"info","time":1716639611000,"service":"pkb","tool":"now","phase":"start"}
{"level":"info","time":1716639611000,"service":"pkb","tool":"now","phase":"ok","dur_ms":0.1}
{"level":"warn","time":1716639612000,"service":"pkb","tool":"echo","phase":"err","dur_ms":0.6,"type":"ZodError"}

And the matching samples-pretty.log after piping through pino-pretty with ANSI colour stripped (redirect to file with > instead of copy-pasting from the terminal):

INFO [pkb] phase: ready transport: stdio
INFO [pkb] (echo) phase: start
INFO [pkb] (echo) phase: ok dur_ms: 0.4 text_len: 5
INFO [pkb] (now)  phase: start
INFO [pkb] (now)  phase: ok dur_ms: 0.1
WARN [pkb] (echo) phase: err dur_ms: 0.6 type: ZodError

Reading the samples:

Common mistake

Running pino-pretty interactively and copy-pasting from the terminal — ANSI escape codes leak in and the file is no longer plain text. Redirect with > instead (npm run dev | npx pino-pretty > samples-pretty.log), then re-open the file in a plain-text editor to verify there's no  garbage. A second common variant: forgetting the 2>/dev/null on the raw capture, so build warnings end up between JSON lines and jq chokes.

What you learned

Lesson 13 puts this server in a Docker image, swaps stdio for HTTP+SSE, and fronts it with Caddy so it gets a TLS certificate automatically. The tools.ts / schemas.ts / logger.ts you wrote stays untouched — only server.ts picks up a transport switch keyed off PKB_TRANSPORT.