How-to › Expose your API to agents

How to shape a tool result so an agent can reason about it#

Return the fields an agent decides on, in that order, with the ids its next call needs, and measure what Markdown, compact JSON, and structuredContent cost.

Audience
API producer
Level
intermediate
Topic
Design tool schemas agents can use
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Compact JSON in a text blockA result a program parses as well as a model, with field names that carry meaningPunctuation the model pays for on every call, and no schema a client can check it againstThe result is read by a model alone, or the client validates results
Markdown text resultA result only a model reads, where labels and order do the explainingNothing a program can parse without a second format, and labels you invent per toolA program consumes the result, or two tools must agree on a shape
MCP structuredContent with outputSchemaClients that validate or type tool results, and a shape you are willing to promiseThe payload carried twice, as JSON text and as structuredContent, and a schema to keep in step with the handlerOlder 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.

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

  1. o200k_base is the encoding tiktoken opens its README with, beside gpt-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

  2. 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

  3. The specification points out that structuredContent is 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.