Your meter_read tool returns what the API returned: 40 KB of JSON with a hundred hourly readings,
an _etag on every row, and the status the agent asked about buried under data.attributes. One
call costs the agent fourteen thousand tokens of context. The follow-up call then fails, because the
customer id it needed sat three levels down in relationships.
What you get
You will end up with one selection function and three forms of it, Markdown, compact JSON, and
structuredContent, each measured in tokens against the raw response. This is for you if your
tools pass API responses through and agents lose the thread.
Short answer
Build the result from the raw response rather than passing it through. Pick the fields an agent
decides on and put them first, drop nulls, audit columns, and internal ids, and keep the
identifiers a follow-up call needs. Render the selection as Markdown for a model to read, or as
compact JSON when a program parses it too. For a client that should validate, add an
outputSchema and return structuredContent, and repeat the JSON in a text block.
You will need
Node 22 or later, @modelcontextprotocol/server and @modelcontextprotocol/client, zod for the
output schema, and js-tiktoken to count tokens. Verified 2026-10-21 against Node 22.22.0,
@modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, zod 4.6.7, and
js-tiktoken 1.0.21. A fixture plays the API: a document of about 40 KB in the data, included,
meta shape, built from a fixed seed, so nothing crosses the network and every number reproduces.
The specification’s
tool result section
defines the content blocks, and its
structured content rules
ask a tool that returns structuredContent to repeat the JSON in a text block. Token counts use
the o200k_base encoding from tiktoken, a stand-in for
whichever tokenizer your model uses.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Compact JSON in a text block | A result a program parses as well as a model, with field names that carry meaning | Punctuation the model pays for on every call, and no schema a client can check it against | The result is read by a model alone, or the client validates results |
| Markdown text result | A result only a model reads, where labels and order do the explaining | Nothing a program can parse without a second format, and labels you invent per tool | A program consumes the result, or two tools must agree on a shape |
MCP structuredContent with outputSchema | Clients that validate or type tool results, and a shape you are willing to promise | The payload carried twice, as JSON text and as structuredContent, and a schema to keep in step with the handler | Older clients that read text only, or a result whose shape changes often |
Markdown reads cheapest, at 118 tokens here, but only a model can consume it. Compact JSON costs nine tokens more and gives a program the same fields by name. Structured content costs the model the same tokens and the wire almost twice the bytes, 760 against 415, in exchange for a schema the server enforces and the client can check. The selection step is what saves the fourteen thousand tokens; the rendering decides who else can read the result.2
Select the fields in the order the agent decides
Selection is one function that builds a new object. Building rather than filtering is the point: a
field reaches the agent because somebody chose it, so nulls, audit columns, tenancy ids, and _etag values
are absent by construction. The order is the order of the decision: status and open alerts first,
consumption next, then the plan and region, then the identifiers a follow-up call needs.
export function select(raw) {
const meter = raw.data.attributes
// Readings in time order, whatever order the API listed them in.
const readings = raw.included.filter((r) => r.type === 'reading').map((r) => r.attributes)
.sort((a, b) => Date.parse(a.recorded_at) - Date.parse(b.recorded_at))
const alerts = raw.included.filter((r) => r.type === 'alert' && !r.attributes.acknowledged)
const last = readings.at(-1)
// The reading exactly a day before the last one. A gap there, or less than a
// day of history, leaves the figure out rather than computing it over the
// wrong span.
const dayAgo = last && readings.find((r) => Date.parse(r.recorded_at) === Date.parse(last.recorded_at) - 86_400_000)
return {
status: meter.status,
open_alerts: alerts.map((a) => ({ kind: a.attributes.kind, severity: a.attributes.severity, opened_at: a.attributes.opened_at })),
...(last && { last_reading: { recorded_at: last.recorded_at, kwh: last.kwh } }),
...(dayAgo && { kwh_last_24h: Math.round((last.kwh - dayAgo.kwh) * 10) / 10 }),
plan: meter.plan,
region: meter.region,
id: raw.data.id,
serial: meter.serial,
customer_id: raw.data.relationships.customer.data.id,
site_id: raw.data.relationships.site.data.id,
}
}
The readings are put in time order before anything is read from them, and the 24-hour figure needs the reading from exactly a day before the last. A meter with less than a day of history, or a gap at that hour, gets no figure rather than one over the wrong span.
The identifiers stay because the next call needs them.
Anthropic’s guidance on writing tools
draws the line in the same place. Drop low-level technical identifiers such as a uuid or a
mime_type, and keep the names and ids that inform the agent’s next action. The id,
customer_id, and site_id here are arguments to other tools, which is the test for keeping one.
node demo.mjs
raw document: 101 included records, top-level keys data, included, meta
selected: status, open_alerts, last_reading, kwh_last_24h, plan, region, id, serial, customer_id, site_id
Render one selection three ways
The three forms share the selection and differ only in the rendering. Markdown labels each value
for a reader. Compact JSON keeps the keys for a parser. The structured form returns the object as
structuredContent and repeats it as JSON text, because the specification asks for the text block
and the SDK does not add it for you.
export function render(shape, raw) {
switch (shape) {
case 'raw':
return text(JSON.stringify(raw))
case 'pruned':
return text(JSON.stringify(prune(raw)))
case 'markdown':
return text(asMarkdown(select(raw)))
case 'json':
return text(JSON.stringify(select(raw)))
case 'structured': {
const summary = select(raw)
// The spec asks for the serialized JSON in a text block beside
// structuredContent, for clients that read only text. The SDK does not
// add it for you.
return { content: [{ type: 'text', text: JSON.stringify(summary) }], structuredContent: summary }
}
default:
throw new Error(`no shape named ${shape}`)
}
}
node demo.mjs
markdown text block
## Meter mtr_8f2, serial SN-40199
- status: active, plan team, region eu-west
- open alerts: voltage_sag (warning) since 2026-09-30T02:00:00.000Z
- last reading: 4199.2 kWh at 2026-09-30T10:00:00.000Z
- consumption, last 24h: 48.3 kWh
- for follow-up calls: customer cus_3f91, site site_77a1
compact JSON text block
{"status":"active","open_alerts":[{"kind":"voltage_sag","severity":"warning","opened_at":"2026-09-30T02:00:00.000Z"}],"last_reading":{"recorded_at":"2026-09-30T10:00:00.000Z","kwh":4199.2},"kwh_last_24h":48.3,"plan":"team","region":"eu-west","id":"mtr_8f2","serial":"SN-40199","customer_id":"cus_3f91","site_id":"site_77a1"}
structured result, through the client
outputSchema advertised with required: status, open_alerts, plan, region, id, serial, customer_id, site_id
structuredContent.status = active, kwh_last_24h = 48.3
text block is the same JSON: true
The structured server advertises MeterSummary as outputSchema in tools/list, derived from the
zod schema with the two reading fields optional, and validates each result against it before
sending, as
the SDK’s tools guide describes. The
last line is the specification’s request met: the text block and structuredContent carry the
same JSON.
Measure what each shape costs
Five servers, one per shape, each called once through the SDK’s in-memory client. The text column is what a model reads. The result column is what the wire carries.
node measure.mjs
shape text bytes text tokens result bytes structuredContent
raw 40498 14070 46727 none
pruned 21456 7161 25005 none
markdown 291 118 335 none
json 324 127 415 none
structured 324 127 760 10 keys
Pruning the raw document, which drops nulls, audit fields, and internal ids while keeping its structure, halves it to 7,161 tokens, so the readings are the weight rather than the metadata. Selection takes it to 127, under one percent of the original. Markdown and JSON land within nine tokens of each other. The structured form costs the model the same 127 tokens and the wire almost twice the bytes, because the same JSON travels twice.
Check it worked
Run the tests and look for # pass 10. Two of them check the contract rather than the content: the
structured form’s text block equals JSON.stringify(structuredContent), and an SDK client refuses
a structuredContent that breaks the advertised schema with a -32602 error.
node --test shapes.test.mjs
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 880.945568
When it goes wrong
The tool passes the API response through because the API returned it. The pitfall script measures that, then shows what happens when the output schema and the handler drift apart, first in a server that validates and then in one that does not.
node pitfall.mjs
raw passthrough
14070 tokens, of which 12437 are the 100 hourly readings
the selected summary is 127 tokens
handler output drifts from the schema, SDK server
isError true: Output validation error: Invalid structured content for tool meter_read: kwh_last_24h: Invalid input: expected number, received string
the same drift from a server that does not validate, SDK client
client refused, code -32602: Structured content does not match the tool's output schema: data/kwh_last_24h must be number
The pass-through is 14,070 tokens, and 12,437 of them are hourly readings the agent did not ask about. That sits under the 25,000-token default cap the Anthropic guidance reports for a tool result in Claude Code, so Claude Code passes it on whole. It then takes the room the rest of the task needed, where selection would have cost 127 tokens.
The drift is a consumption figure formatted as a string. The SDK server refuses to send it and
answers with isError: true, so the agent reads an Output validation error where it expected a
meter. That is the correct failure, because the contract held. A hand-rolled server that skips the
check sends the result anyway, and the SDK client rejects it on arrival with -32602, as the
specification permits, since clients should validate structured results. A handler change without
a schema change is a broken tool either way, and the fifth test is the one that catches it before a
client does.
When not to do this
Do not summarize away the identifiers. A Markdown result that reads well and omits customer_id
sends the agent back for another call, or into guessing, which is the failure the problem statement
opened with.
Do not add an outputSchema to a result whose shape you cannot promise. The server refuses a result
that drifts, and an SDK client refuses one a careless server lets through. A schema therefore
turns every shape change into a failed call until the handler and the schema move together. Version the two as
one, or leave the result as text.
Do not count on structuredContent reaching the model. The specification calls it server-produced
result data and sets it apart from a model’s structured output. A host that gives its model only
the content array, the case the backwards-compatibility rule exists for, gets an empty one from a
tool that returns only structuredContent.3
Do not choose the rendering on taste. The Anthropic guidance says the response format itself changes evaluation results and that no format wins everywhere, so measure accuracy on your own task set. This page measures cost, which is the half you can measure without a model.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @modelcontextprotocol/server 2.3.1,
@modelcontextprotocol/client 2.3.1, zod 4.6.7, and js-tiktoken 1.0.21. Every output block is
what the command preceding it printed. No model was run. The token counts come from the
o200k_base encoding, and the statements about which format a model reads better belong to the
linked Anthropic guidance rather than to a measurement made here.
Footnotes
-
o200k_baseis the encodingtiktokenopens its README with, besidegpt-4o, and js-tiktoken is the JavaScript port whose full build carries every rank table, so a count needs no network. Claude’s tokenizer is not published. Its token counting endpoint counts under the tokenizer of the model you name. The same page records that later models produce about 30 percent more tokens than earlier ones for the same text. A token count is a measurement of one tokenizer on one day, and the ratios on this page are what survive the move. ↩︎ Back to text -
CommonMark, the specification linked in the table, stands at version 0.31.2, dated January 2024 and signed by John MacFarlane, and its version number has stayed below one through every release. The Markdown a model learned from the web is whichever dialect each page’s renderer accepted, which is one reason the guidance linked earlier declines to name a winning format. ↩︎ Back to text
-
The specification points out that
structuredContentis unrelated to LLM structured outputs. The sentence is there because the two features share an adjective, while one describes a server’s result and the other constrains a model’s output. The tools page sets the disclaimer in a box of its own, which is where a specification puts the sentence its readers have already got wrong. ↩︎ Back to text