Home Blog Contact
Home/Blog/How to Return Structured Output From an MCP T…
How toLLM EngineeringMCPTool DesignAgents

How to Return Structured Output From an MCP Tool

11 min readBy Miloš Mitrović

If your MCP tool returns data that another program will read, declare an outputSchema on the tool and populate structuredContent in the result. The client then gets a typed object it can validate and hand to code, instead of a text blob the model has to re-parse. The catch is that the spec also asks you to send a serialized copy of the same JSON as text, so a careless implementation pays for the payload twice in context.

Key takeaways

  • Structured tool output landed in the 2025-06-18 revision of MCP as two fields, outputSchema on the tool definition and structuredContent on the call result.
  • The output schema describes an object. Scalars and arrays get wrapped, so design the top level as a record with named fields from the start.
  • The spec says a tool returning structured content SHOULD also return the serialized JSON in a text block for older clients, which means large payloads can be transmitted twice.
  • Keep the schema to a plain subset of JSON Schema. Nested $ref, oneOf and conditional keywords are validated unevenly across clients.
  • Use schemas for results that feed code or another tool call. Skip them for results whose value is prose the model reads once.
  • Adding an optional field is safe. Removing a field, renaming it, or tightening an enum is a breaking change for anything downstream that validates.

What structured tool output is in MCP

Structured tool output is a contract that lets a tool say, in advance, what shape its successful result takes, and then return a parsed object that matches it. Before it existed, every MCP tool result was a list of content blocks, and anything machine readable had to be smuggled inside a text block as JSON. The tools section of the MCP specification now defines an optional outputSchema field on the tool and a structuredContent field on the result, and it says clients SHOULD validate the structured result against the declared schema.

The wire format is small enough to read directly. A tool listing entry looks like this:

{
  "name": "get_order",
  "title": "Get order",
  "description": "Fetch one order by id.",
  "inputSchema": {
    "type": "object",
    "properties": { "orderId": { "type": "string" } },
    "required": ["orderId"]
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "orderId": { "type": "string" },
      "status": { "type": "string", "enum": ["pending", "paid", "shipped", "cancelled"] },
      "totalCents": { "type": "integer" },
      "currency": { "type": "string" }
    },
    "required": ["orderId", "status", "totalCents", "currency"]
  }
}

And the matching result:

{
  "content": [
    { "type": "text", "text": "{\"orderId\":\"A-1099\",\"status\":\"shipped\",\"totalCents\":4250,\"currency\":\"EUR\"}" }
  ],
  "structuredContent": {
    "orderId": "A-1099",
    "status": "shipped",
    "totalCents": 4250,
    "currency": "EUR"
  }
}

The same revision also added a resource_link content type, which matters for the sizing decision later in this post. Both changes are listed in the 2025-06-18 changelog.

How do I declare an output schema in a Python MCP server

In the Python SDK you do not hand write the schema, you annotate the return type and FastMCP generates it. The official python-sdk derives outputSchema from the return annotation and fills structuredContent from the returned value, for dataclasses, Pydantic models, TypedDicts and plain typed dicts.

from dataclasses import dataclass
from typing import Literal

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")


@dataclass
class OrderSummary:
    order_id: str
    status: Literal["pending", "paid", "shipped", "cancelled"]
    total_cents: int
    currency: str


@mcp.tool()
def get_order(order_id: str) -> OrderSummary:
    """Fetch one order by id."""
    row = db.fetch_order(order_id)
    return OrderSummary(
        order_id=row["id"],
        status=row["status"],
        total_cents=row["total_cents"],
        currency=row["currency"],
    )

One behaviour catches people out. If you annotate a return type that is not an object, such as list[str] or int, the SDK wraps it so the top level stays an object, typically under a result key. The agent then sees {"result": [...]} rather than the bare list, and any client code you wrote against the bare list breaks. Decide the envelope yourself instead of inheriting the wrapper, because once agents are running against your server that key name is part of your contract.

The other trap is annotating a type your serializer cannot round trip. Datetime objects, Decimals and enums outside Literal produce schemas that are either wrong or rejected. Convert to strings and integers at the boundary and keep the domain types inside your service layer. If you are starting from scratch, the FastMCP server walkthrough covers the surrounding setup.

How do I do the same in TypeScript

In TypeScript you pass an outputSchema shape to registerTool and return both fields from the handler. The typescript-sdk converts the Zod shape to JSON Schema for the tool listing and validates your handler output against it before the result leaves the process.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "orders", version: "1.4.0" });

server.registerTool(
  "get_order",
  {
    title: "Get order",
    description: "Fetch one order by id.",
    inputSchema: { orderId: z.string() },
    outputSchema: {
      orderId: z.string(),
      status: z.enum(["pending", "paid", "shipped", "cancelled"]),
      totalCents: z.number().int(),
      currency: z.string().length(3),
    },
  },
  async ({ orderId }) => {
    const row = await db.fetchOrder(orderId);
    const payload = {
      orderId: row.id,
      status: row.status,
      totalCents: row.total_cents,
      currency: row.currency,
    };
    return {
      structuredContent: payload,
      content: [{ type: "text", text: JSON.stringify(payload) }],
    };
  },
);

Declaring outputSchema and then returning only content is an error in this SDK, not a silent degradation. That is the behaviour you want. The failure surfaces in your own tests rather than as an agent quietly reverting to string parsing in production. The TypeScript SDK guide covers registration and transport wiring in more detail.

Why does the result still contain a text copy

The text copy exists because a client that predates the 2025-06-18 revision ignores structuredContent entirely and would otherwise see an empty result. The spec states that for backwards compatibility a tool returning structured content SHOULD also return the serialized JSON in a text content block, which is why both fields appear in the example above.

The cost is straightforward. A client that understands both fields may receive the same payload twice, and unless it deduplicates, both copies enter the model context and both are billed. On a 200 byte order summary this is noise. On a 40 KB report it is not.

Treat the text mirror as a compatibility shim with a budget. If the serialized payload is larger than a few kilobytes, the mirror should be a short human readable summary rather than a byte for byte copy, and the structured field carries the detail.

That is a deliberate deviation from the SHOULD, and it is the right call for large results, because an old client reading a summary still behaves sensibly while a new client gets the full object. What you must not do is put different facts in the two fields. Same data, different fidelity, is fine. Contradictory data is a debugging nightmare, since which one the agent acted on depends on the client. The broader sizing question is covered in keeping MCP tool results out of your context window.

What should go in the schema, and what should stay out

Put in the schema the fields a caller will branch on, filter by, or pass into the next tool call. Keep out anything whose only consumer is a human eye, and keep out bulk payloads.

Three concrete rules I apply:

  • Identifiers and enums always go in. An agent chaining calls needs orderId and status as typed fields, because that is what the next call takes as an argument. Anthropic's guidance on writing tools for agents makes the same point about returning identifiers the model can feed back in rather than prose it must re-extract.
  • Bulk content goes behind a resource link. If the tool produces a 2 MB CSV or a long document, return a resource_link content block plus a structured summary with row counts, column names and the resource URI. The client fetches the body only when it needs it.
  • Pagination state goes in the schema, not the prose. A nextCursor field that an agent can read as a string is worth more than a sentence saying there are more results. Make it explicitly nullable so the terminating condition is unambiguous.

On schema expressiveness, stay boring. MCP schemas are JSON Schema, and draft 2020-12 has plenty of machinery you can express and that many clients will not fully validate. Objects with typed properties, required, enum, arrays of one item type, and a nesting depth of two or three. Discriminated unions expressed with oneOf are where interop tends to break, and a flat object with a kind field and optional branches is easier for both validators and models.

How do I change an output schema without breaking running agents

Additive changes are safe, subtractive and narrowing changes are not. Adding an optional property does not invalidate any existing payload, so existing clients keep validating successfully and simply ignore the new field. Everything else needs care:

ChangeSafe?Why
Add optional propertyYesOld payloads still validate, old clients ignore it
Add required propertyNoClients that pinned a stricter validator may reject, and any cached schema disagrees with the payload
Remove a propertyNoDownstream code reading it gets undefined at the worst moment
Rename a propertyNoEquivalent to a remove plus an add
Add an enum valueRiskyAgent branching logic has no case for it, so behaviour is unspecified rather than wrong
Remove an enum valueNoNarrowing rejects payloads that were valid yesterday
Loosen a type, integer to numberRiskyValidates fine, but consumers doing integer arithmetic may not

For a breaking change, register a new tool name and keep the old one returning the old shape until telemetry shows it is unused. Silently changing the shape under a stable name is the failure that produces a support ticket three weeks later from a client that cached your tool list. The general approach is in changing an MCP tool without breaking agents.

One asymmetry worth noting. Output schemas make breaking changes louder, not rarer. A client validating against a stale schema fails fast with a validation error instead of an agent quietly misreading a renamed field. That is an improvement, but only if your error path is useful, which is covered in returning MCP tool errors agents can recover from.

How do I verify a client actually uses it

Check three things in order, because each one fails differently. First, confirm the schema is published. Call tools/list and look for outputSchema on the tool. If it is missing, the problem is in your registration, typically an un-annotated return type in Python or a shape the SDK could not convert.

Second, confirm the result carries the field. Call the tool and inspect the raw response for structuredContent. MCP Inspector shows both the listing and the raw result, which is faster than adding logging on the server.

Third, confirm the client consumes it. This is the step people skip. Client support varies, and a client that ignores structuredContent will fall back to the text block without telling you, so your carefully typed object arrives at the model as a JSON string anyway. The way to test is to make the two fields distinguishable during development, for example by putting a short summary sentence in the text block and full detail in the structured field, then asking the agent for a detail that appears only in the structured field. If it cannot answer, the client is not passing the structured payload through.

Add an assertion to your test suite for the first two, since both are pure protocol checks:

result = await session.call_tool("get_order", {"order_id": "A-1099"})

assert result.structuredContent is not None
assert result.structuredContent["status"] in {
    "pending", "paid", "shipped", "cancelled",
}
# the text mirror must parse and must not contradict the structured field
mirror = json.loads(result.content[0].text)
assert mirror["orderId"] == result.structuredContent["orderId"]

Trade-offs and what I would do

Output schemas are not free, and three costs are worth stating plainly. They add a maintenance surface that has to stay in sync with your database and your API version. They can double payload size through the text mirror. And they can make a tool look more precise than it is, since a schema constrains shape and says nothing about whether totalCents includes tax.

My rule is to decide by consumer, not by taste:

  • Declare a schema when the result feeds another tool call, a code execution step, a client side filter, or anything that does field access. Typed identifiers and enums pay for themselves within a day.
  • Skip the schema when the result is something the model reads once and summarizes, such as a diagnostic explanation or a search snippet. Wrapping prose in {"text": "..."} adds ceremony and no information.
  • Split the tool when you want both. A search_orders that returns typed rows and a separate explain_order_status that returns prose is easier to schema than one tool trying to be both.

On the text mirror, I mirror in full below roughly 2 KB serialized and summarize above it. On schema style, I keep a flat object, no $ref, at most one level of nested objects, and arrays of a single homogeneous type. On error results, I leave the structured field absent and set isError with a text explanation, because an error is not the success shape and forcing it into the same schema produces a union that every consumer then has to discriminate.

One last point on expectations. A schema on the MCP side is not the same guarantee as provider level constrained decoding. It describes what your server returns, and your server is ordinary code that can return a well typed object full of nonsense. If you also need the model's own output to conform to a shape, that is a separate mechanism, covered in getting reliable JSON from an LLM. Structured tool output removes a parsing step. It does not remove validation of your own data.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.