Return structured tool output, not prose for the model to parse#16
Conversation
Every tool flattened its result into a hand-formatted text blob, so an
agent asking for job state got
Job abc [crawl] - status: running (12/50)
Created: 2026-01-01T09:14:22Z | Finished: -
and had to pattern-match `status` out of a sentence, split `12/50` on a
slash, and read an em dash as null. The JS SDK already returns a typed
BulkJob; this layer was the only place throwing that structure away.
Each tool now declares an outputSchema and returns structuredContent
next to the existing text block. One shared job schema covers bulk,
crawl, get_job, and wait_for_job, since get_job is polymorphic by
design; crawl-only fields (depth, truncated, truncatedReason) are
optional and discriminated by `kind`.
jsonify() is load-bearing rather than cosmetic: Date fields
(createdAt, finishedAt, retrievedAt) and the SDK's computed ok/done
getters would both fail output validation if the SDK object were passed
through as-is.
The text block stays. The spec asks a tool returning structured content
to also return a functionally equivalent unstructured copy, and dropping
it would silently blank the tool on hosts that don't read structured
output. That costs payload: on an 8.5 KB article the markdown appears in
both shapes, measured at 2.02x the text-only baseline.
Errors stay text-only and are exempt from validation
(McpServer.validateToolOutput returns early on isError), so the error
path is unchanged.
Tests stub globalThis.fetch rather than the client methods, so each case
drives the whole chain: canned API JSON -> the real SDK parser -> jsonify
-> the MCP SDK's own output validation. A passing call is therefore proof
of schema conformance. Verified they detect the bug: with the source
reverted and the tests kept, 8 of 9 fail. The 9th passes either way by
design -- it guards the error path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the local dev environmentBooted the API on All 7 tools returned
The error path was also confirmed organically rather than by construction — every rate-limit 429 encountered during testing returned Unrelated issue found while testingNot caused by this PR and not touched by it, but worth recording: the SDK's polymorphic Client pacing of ~400ms between requests was needed to get Not coveredNo run against a real MCP host (Claude Desktop / Cursor) — the client here is the SDK's in-memory transport. |
The problem
Every MCP tool flattened its result into a hand-formatted text blob. An agent polling a job got this:
To decide whether the job was done, a model had to pattern-match
status:out of a sentence, split12/50on a slash, read—as null, and interpret✓/✗as per-item success. The JS SDK already returns a typedBulkJob— this adapter was the only place throwing that structure away.The change
Every tool now declares an
outputSchemaand returnsstructuredContentalongside the existing text block:{ "kind": "crawl", "jobId": "job_abc", "status": "done", "total": 12, "completed": 12, "done": true, "truncated": false, "results": [ { "url": "https://example.com/a", "depth": 1, "ok": true, "markdown": "# A" }, { "url": "https://example.com/b", "depth": 1, "ok": false, "error": "target_timeout" } ] }bulk,crawl,get_job, andwait_for_job—get_jobis polymorphic by design, so crawl-only fields (depth,truncated,truncatedReason) are optional and discriminated bykind.jsonify()is load-bearing, not cosmetic.Datefields (createdAt,finishedAt,retrievedAt) and the SDK's computedok/donegetters would both fail output validation if the SDK object were passed through as-is.McpServer.validateToolOutputreturns early onisError).The text block stays — and what that costs
The spec asks a tool returning structured content to also return a functionally equivalent unstructured copy, and dropping it would silently blank the tool on hosts that don't read structured output.
That duplication has a measured price. On an 8.5 KB article the markdown appears in both shapes:
structuredContentReviewer call: whether the model pays for both halves or just the structured one depends on the host, which I did not measure — I'm only claiming the wire cost. If the trade isn't worth it, cutting the text block is a one-line change in
ok().Verification
npm test→ 11/11, typecheck clean, againstmain's@types/node26.1.1.The new suite stubs
globalThis.fetchrather than the client methods, so each case drives the whole chain: canned API JSON → the real SDK parser (snake_case → camelCase,Datecoercion, the computed getters) →jsonify→ the MCP SDK's own output validation. A passing call is therefore itself proof of schema conformance.I confirmed the tests detect the bug rather than trusting a green run — with the source reverted and the tests kept, 8 of 9 fail:
The 9th passes either way by design: it guards the error path, asserting a failure still returns
isErrorwithout tripping output validation.test/structured-output.test.mjsis wired intonpm testby explicit filename.smoke.mjsmakes a live API call, so a directory glob would have pulled the network into CI.Not verified
No run against a live WellMarked API or a real MCP host — every test here is hermetic. Worth exercising the remote connector once before release, since
http.tsandindex.tssharecreateServer()and the parity test only pins that the two agree.Context
This came out of an audit for
text/*responses across the API and website. Both were already 100%application/json(all API routes plus 75 website routes checked, zero non-JSON), so the "model parses JSON as text" problem lived here rather than in the HTTP layer.🤖 Generated with Claude Code