Skip to content
MCP Five

The ServerProject #110 / 51

What a tool sends back

Text, images, resource links, structuredContent — and the difference between a protocol error and one the model can fix.

Project #1's tools all return one line of text, because that makes the mechanism visible. A tool can return considerably more than that, and the choices matter — particularly the two ways of reporting failure, which are not interchangeable.

Part 1 · Content blocks

A tool result carries a content array, and each entry has a type.

json
{
  "resultType": "complete",
  "content": [
    { "type": "text", "text": "Current weather in New York:\nTemperature: 72°F" }
  ],
  "isError": false
}
TypeCarriesUse it for
texta stringalmost everything
imagebase64 data + mimeTypea chart, a screenshot, a rendered diagram
audiobase64 data + mimeTypetranscription input, generated speech
resource_linka uri, plus name and descriptionpointing at something big rather than inlining it
resourcean embedded resource, contents and allinlining a file the model needs right now

Note that a result may contain several blocks of different types. A tool that renders a chart can return the image and a sentence describing it, so a model that cannot see images still gets something useful.

If your tool finds ten files, you have two options. Inline all ten — which costs you every token of all ten, on every subsequent iteration, forever (the meter explains why that compounds). Or return ten links and let the host fetch only the ones it needs.

json
{
  "type": "resource_link",
  "uri": "file:///project/src/main.rs",
  "name": "main.rs",
  "description": "Primary application entry point",
  "mimeType": "text/x-rust"
}

Part 2 · Structured output

Text is what the model reads. Sometimes you also want data a program can rely on. That is outputSchema plus structuredContent.

json
{
  "name": "get_weather_data",
  "inputSchema": { "type": "object", "properties": { "location": { "type": "string" } } },
  "outputSchema": {
    "type": "object",
    "properties": {
      "temperature": { "type": "number" },
      "conditions":  { "type": "string" },
      "humidity":    { "type": "number" }
    },
    "required": ["temperature", "conditions", "humidity"]
  }
}

And the result carries both:

json
{
  "resultType": "complete",
  "content": [
    { "type": "text", "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}" }
  ],
  "structuredContent": { "temperature": 22.5, "conditions": "Partly cloudy", "humidity": 65 }
}

Two rules attach to it:

  • If you declare an outputSchema, servers MUST return structured results that conform to it, and clients SHOULD validate them.
  • For backwards compatibility, a tool returning structured content SHOULD also return the serialised JSON as a text block — which is why the example above says the same thing twice.

When to bother

Reach for it when something other than the model consumes the result — a UI rendering a card, a script asserting on a value, an eval checking a number.

Skip it when the only consumer is the model. Project #1 deliberately skips it, and the reason is worth restating: a plain sentence is what lets the loop carry Total: 44 into the next tool's count argument with no code in between.

Part 3 · Two kinds of error, and choosing wrong is expensive

This is the part of the page that will save you real time.

MCP has two error mechanisms, and they mean different things to the model.

Protocol error — JSON-RPC error

Something is wrong with the request itself.

Unknown tool. Malformed request. Server crashed.

{ "error": { "code": -32602, … } }

The model is unlikely to be able to fix it.

Tool execution error — isError: true

The call was well-formed; the operation failed.

API down. Date in the past. Not enough cookies.

{ "result": { "content": [...], "isError": true } }

The model can self-correct from it.

The spec is explicit about the consequence:

Clients MAY provide protocol errors to language models, though these are less likely to result in successful recovery. Clients SHOULD provide tool execution errors to language models to enable self-correction.

What a good execution error looks like

json
{
  "resultType": "complete",
  "content": [{
    "type": "text",
    "text": "Invalid departure date: must be in the future. Current date is 08/08/2025."
  }],
  "isError": true
}

Look at what that sentence gives the model: what was wrong, what the rule is, and the fact it needs to construct a valid retry. It can fix this without asking anybody.

Compare it with {"error": "invalid input"}, which gives the model nothing and guarantees either a blind retry or a shrug.

Part 4 · Stateful tools, without sessions

The modern protocol has no session. A server cannot rely on per-connection state to relate one call to the next, because there is no connection.

So how does a shopping cart work?

The spec's guidance — explicitly non-normative, because the protocol has no concept of it — is an explicit handle:

jsonc
// → tools/call
{ "name": "create_basket", "arguments": {} }
 
// ← result
{ "content": [{ "type": "text", "text": "Created basket bsk_a1b2c3" }],
  "structuredContent": { "basket_id": "bsk_a1b2c3" } }
 
// → tools/call
{ "name": "add_item", "arguments": { "basket_id": "bsk_a1b2c3", "sku": "..." } }

The model carries the handle forward. The server stores state under that key.

Four things to get right, and the first is a security issue:

ConcernWhat to do
AuthorizationA handle is a name, not a capability. Validate the caller against it on every call. On an unauthenticated server the handle is necessarily a bearer token, so give it real entropy and a bounded lifetime.
OpacityHandles that encode structure invite guessing. Use opaque ids.
LifetimeState the retention policy in the creation tool's description, so the model can see it when deciding to create state.
ExpiryA call on a dead handle should return an execution error saying so, so the model can create a new one.

Part 5 · A detail that pays for itself

One line in the spec, easy to skim, worth real money:

Servers SHOULD return tools in a deterministic order […] Deterministic ordering enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context.

Prompt caching is a prefix match. Your tool definitions sit near the front of every request the host makes. If they come back in a different order on different calls, the prefix changes, and every cache hit silently becomes a miss.

No error. Just a bigger bill.

What you now know

  • A result is an array of blocks — text, image, audio, resource_link, resource — and may mix them.
  • resource_link lets you point instead of inline, which is the same context-budget lesson as delegation.
  • outputSchema + structuredContent when a program consumes the result; plain text when the model does.
  • Two error mechanisms: protocol errors the model cannot fix, and isError: true execution errors it can. Never throw for business logic.
  • No sessions: carry state in an explicit handle, authorize it every call, and put its lifetime in the description.
  • Return tools in a deterministic order, or you break everybody's prompt cache.