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:
- Same shape as
echoTool. Child logger at the top,t0captured before the parse,phase: "start"line before any work,phase: "ok"withdur_mson success,phase: "err"with the error type (never the value) on failure. The repeated boilerplate is intentional — L15 will extract it into a helper. Until then, every tool follows the same pattern so the audit trail is uniform. z.object({}).strict(), not barez.object({}). The.strict()modifier is what makes Zod reject unknown keys at the boundary. Without it,{"foo": "bar"}validates silently — bad keys ride through your handler and get logged downstream. TheadditionalProperties: falsein the JSON Schema only enforces the contract on the wire side;.strict()enforces it on the runtime side. You need both.- Inspector self-check. After rebuilding, the Tools list
shows two entries (
echoandnow). Callingnowwith{}returns a singleTextContentlike2026-05-25T14:32:01.123Z, and your terminal shows exactly two log lines per call (phase=start,phase=okwith a tinydur_ms).
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:
- The 12 000-char payload does not appear anywhere in the
log line.
tool,phase,dur_ms, andtypeare the only carrier fields — notext, noargs, noreceived. - The
phaseiserr(the failure case), notok. The Zod parse throws, thecatchblock runs, and thethrowre-raises so the dispatcher returnsisError: trueto the client.
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:
- 5–8 lines, defensible. The self-check asks for a short paragraph, not an essay. Pick one concrete worst-case input (pasted document with secrets, prompt-injection attempt, OAuth bearer paste) and one concrete downstream propagation path (rotation, shipping, backup) — both grounded in real operational hygiene.
- "Truncated" is not "redacted". A 200-char truncation of a 12 000-char bearer token is still 200 chars of bearer token. If you can't classify the input as safe (and at the boundary, by definition, you can't), log its shape — type, length, field name — never its bytes.
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:
- Same six events, two formats. The line counts match
one-for-one.
pino-prettynever adds or drops lines — it only reshapes each JSON line into a human-readable form. If the raw file has 6 lines and the pretty file has 5, you lost a line during capture (often becausepino-prettybuffered the last line and you killed the process before it flushed). jqparses every raw line. The self-check asks forjq -c '.' < samples-raw.logto succeed line-by-line. Ifjqerrors on any line, you accidentally captured stderr or pretty output into the raw file — redirect explicitly:npm run dev 2>/dev/null > samples-raw.log.- The pretty file has no values, only shape. Notice
text_len: 5appears, but the literal word"smoke"from the Inspector call does not. The redaction rule applies beforepino-prettyever sees the line —pino-prettycan only render whatpinoemitted.
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
[36m 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
- Adding a tool is a three-file edit. Schema in
schemas.ts, async function intools.ts, two registrations inserver.ts(advert + dispatch). Every tool looks like this until L15 extracts the boilerplate. Uniformity is the feature — one shape for every tool means one audit query covers every call site. - Boundary validation is a runtime contract.
TypeScript's type system is gone at runtime; Zod is what makes the cast
truthful. The 10 000-char cap held in Task 2 because
EchoInput.parseis the first thing the handler does — not the second, not the "we'll add it later", the first. The same discipline applies to every future tool. - Log the shape, not the value. The Task 2 paragraph is the entire production-logging philosophy compressed into 5 lines: at the boundary you have safe information (type, length, field name) and unsafe information (the bytes). Log only the safe set, every time, by reflex. The L15 audit-log helper is going to make this enforceable rather than cultural — but the culture has to come first.
pino-prettyis a dev tool, not a log format. The raw JSON file is what ships to Loki / Datadog / CloudWatch in production.pino-prettyexists so a human innpm run devcan read the same data without parsing JSON in their head. The two files are the same data, twice — that invariant is what makes the dev workflow trustworthy.
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.