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

Source: https://voxgig.com/howto/shape-tool-results-for-agent-reasoning

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool-result)
defines the content blocks, and its
[structured content rules](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content)
ask a tool that returns `structuredContent` to repeat the JSON in a text block. Token counts use
the `o200k_base` encoding from [`tiktoken`](https://github.com/openai/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](https://www.rfc-editor.org/rfc/rfc8259.html) | 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](https://spec.commonmark.org/) | 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`](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) | 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.

```js title="shapes.mjs"
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](https://www.anthropic.com/research/writing-tools-for-agents)
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.

```bash
node demo.mjs
```

```text output
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.

```js title="shapes.mjs"
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}`)
  }
}
```

```bash
node demo.mjs
```

```text output
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](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools) 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.

```bash
node measure.mjs
```

```text output
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.

```bash
node --test shapes.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Map one tool definition to MCP, OpenAI, Claude, and Gemini](/howto/convert-tool-definitions-between-mcp-openai-claude-gemini)

- [Sanitize tool results before they re-enter the prompt](/howto/sanitise-tool-results-before-reprompting)

- [Measure the token cost of an agent task against your API](/howto/measure-token-cost-per-agent-task)

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

[^1]: `o200k_base` is the encoding [`tiktoken`](https://github.com/openai/tiktoken) opens its README
with, beside `gpt-4o`, and [js-tiktoken](https://github.com/dqbd/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](https://platform.claude.com/docs/en/build-with-claude/token-counting)
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.

[^2]: CommonMark, the specification linked in the table, stands at
[version 0.31.2](https://spec.commonmark.org/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.

[^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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content)
sets the disclaimer in a box of its own, which is where a specification puts the sentence its
readers have already got wrong.