The last post covered discovery: what the model reads to decide how to call a tool. This post covers the other half, tools/call, and what comes back. A tool result is a content array, optionally a block of structured data, and a flag that says whether the tool failed. Each of those has rules worth reading at the byte level, because how a result is shaped decides whether the model can use it.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
The call itself is small. The client sends tools/call with a name and an arguments object that satisfies the tool’s inputSchema. The result is where the detail lives: a content array that is always present, a structuredContent object when the tool declared an outputSchema, and an isError flag when the tool ran and failed.
§Five Kinds of Content
The content array holds blocks, and a single result can mix block types. There are five. A gallery tool that returns one of each produces this, captured from a real tools/call:
[
{ "type": "text", "text": "a result can carry many blocks",
"annotations": { "audience": ["user"], "priority": 0.9 } },
{ "type": "image", "mimeType": "image/png", "data": "UE5HREFUQQ==" },
{ "type": "audio", "mimeType": "audio/wav", "data": "V0FWREFUQQ==" },
{ "type": "resource_link", "mimeType": "text/x-go",
"uri": "file:///project/main.go", "name": "main.go" },
{ "type": "resource", "resource": {
"uri": "file:///note.txt", "mimeType": "text/plain", "text": "embedded inline" } }
]
Each type is its own shape. The distinction that catches people is the last two, so the figure below lays out what each block carries and whether the bytes travel inline or by reference.
textdata + mimeTypedata + mimeTypeuri (plus name, mimeType), no payloaduri with text or base64 blobA resource_link is a pointer: it names a resource by URI and the client fetches it later with resources/read, the subject of the next post. An embedded resource is the opposite, the resource’s bytes carried in the result itself, as text for textual data or a base64 blob for binary. Use the link when the data is large or the client may not need it; embed when you want the model to have it now. Every block type, as the text block shows, can carry annotations: audience for who the block is meant for, priority from 0 to 1 for how important it is, and lastModified for a timestamp. They are the same annotations resources and prompts use.
§Structured Output
Text is for the model to read. Structured output is for a program to consume. A tool that declares an outputSchema promises its result will include a structuredContent object matching that schema. The weather tool’s schemas, generated from its Go input and output types and read from tools/list:
"outputSchema": {
"type": "object",
"properties": {
"temp_c": { "type": "number", "description": "temperature in celsius" },
"conditions": { "type": "string", "description": "short weather description" }
},
"required": ["temp_c", "conditions"],
"additionalProperties": false
}
Call it, and the result carries the data twice:
{
"content": [
{ "type": "text", "text": "{\"conditions\":\"partly cloudy\",\"temp_c\":22.5}" }
],
"structuredContent": { "conditions": "partly cloudy", "temp_c": 22.5 }
}
structuredContent is the typed object, validated against the outputSchema. The content array holds the same data serialized into a text block. The duplication is deliberate: the spec says a tool returning structured content should also put the serialized JSON in a text block, because not every client reads structuredContent yet, and the text block is the fallback every client understands. A server that declares an outputSchema must return conforming structured content, and a client should validate it against the schema. This is the contract that lets a program downstream of the model rely on the shape of a tool’s answer.
§Two Ways to Fail
A tool can fail two ways, and MCP keeps them strictly apart, because the model can do something useful with one and not the other.
A protocol error is a JSON-RPC error, the kind from the grammar post. The request never reached a working handler: the tool does not exist, or the request was malformed. Calling a tool that is not there returns one:
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32602, "message": "unknown tool \"does_not_exist\"" } }
A tool execution error is the opposite. The tool exists, the request was valid, the tool ran, and it failed. That is not a protocol error, it is a normal result with isError set to true. The safe_divide tool, asked to divide by zero, returns this:
{
"content": [{ "type": "text", "text": "cannot divide by zero; pass a non-zero b" }],
"isError": true
}
The difference is what the model can do next. A protocol error is about the request structure, which the model is unlikely to fix by trying again. An execution error is fed back to the model as content, and its text is written for the model to act on: “pass a non-zero b” tells it exactly how to retry. The spec is explicit that tool execution errors “contain actionable feedback that language models can use to self-correct,” and that clients should hand them to the model for that reason. As of 2025-11-25 this line moved: input-validation failures, a value out of range or a date in the wrong format, are reported as execution errors now, not protocol errors, precisely so the model can correct its arguments and call again instead of hitting a dead protocol error it cannot repair.
§The Handler in Go
The Go side of all this is short. A structured-output tool returns its typed Out value, and the SDK fills both structuredContent and the fallback text block:
func weather(ctx context.Context, req *mcp.CallToolRequest, in WeatherIn) (*mcp.CallToolResult, WeatherOut, error) {
return nil, WeatherOut{TempC: 22.5, Conditions: "partly cloudy"}, nil
}
An execution error is a result the handler builds itself, with IsError set and content the model can read:
if in.B == 0 {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: "cannot divide by zero; pass a non-zero b"}},
}, nil, nil
}
Returning a Go error from the handler is different again: the SDK turns a non-nil error into a tool execution error too, so the model still sees it. A protocol error is reserved for the framework, raised when the tool name does not resolve or the arguments fail the input schema. The rule of thumb: if the tool ran and has something to say about the failure, set isError and say it; let the framework handle the cases where the tool never ran.
txn2/mcp-data-platform uses the result shape for more than rows. When an agent calls trino_query through it, the structured content comes back annotated with the table’s owner, quality score, and any deprecation warning from the DataHub catalog, cross-injected at the protocol level, so the model reads the meaning alongside the data instead of guessing at it.
§What’s Next
Tools are actions the model invokes. The next primitive is the model’s reading material. Resources, list, read, templates, and subscriptions covers how a server exposes data the client can attach as context, how a resource_link from a tool result gets fetched, how URI templates parameterize a whole family of resources, and how a client subscribes to one and gets told when it changes.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.