Skip to main content

MCP Tool Outputs: Text, Structured Content and Large Results

Short answer

A tool returns a list of content blocks, and the model reads them as text. If your app needs individual fields, also return structuredContent, a JSON object that matches an outputSchema you declare. Keep results small by returning one page at a time, and return a resource_link for large data instead of the data itself. The Python SDK builds the output schema from your return type, and the TypeScript SDK takes an explicit outputSchema.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 24.19Checked 20 Oct 2026

Before you start

  • Python: Python 3.10 or later and uv.
  • TypeScript: Node.js 22.19 or later and npm.
  • For the checks: MCP Inspector runs through npx and needs Node.js 22.19 or later.
  • Basic comfort with a terminal.

What the model and your app each receive​

A tool result has up to three parts. The content array holds what the model reads: text, images, audio or links. The optional structuredContent field holds a JSON value for your application. The optional outputSchema describes that value, and the tool declares it once, when it's registered.

The spec allows any JSON value in structuredContent, as long as it matches the outputSchema when the tool has one. It also says a tool that returns structured content SHOULD return the same JSON as text in content, for backwards compatibility. A client that doesn't read structuredContent still gets the text.

Here is what the Python get_order tool in the example returns, trimmed:

{
...
"content": [
{
"type": "text",
"text": "{\n \"id\": \"ord_004\",\n \"customer\": \"Acme customer 4\",\n \"total_cents\": 2000,\n \"status\": \"shipped\"\n}"
}
],
"structuredContent": {
"id": "ord_004",
"customer": "Acme customer 4",
"total_cents": 2000,
"status": "shipped"
},
"isError": false
}

Text, structured content or both​

ReturnWhat the model readsWhat your app getsUse it for
Text onlyThe text in contentNothing beyond the textProse answers and short status messages
Text and structuredContentThe JSON or prose in contentThe fields in structuredContentResults with IDs, totals, cursors or other values a program reads
resource_linkA URI and a nameNothing beyond the linkData too large to return inline, which the model can fetch by URI

In Python you return one value, and the SDK fills in both parts from it. In TypeScript you write both parts in the handler. The examples below show each approach.

1. Declare the output shape​

Python builds the output schema from the return annotation. Pydantic models give you the fields and their descriptions. These are the two models the example uses:

orders.py
class Order(BaseModel):
id: str = Field(description="Order ID, such as ord_004")
customer: str = Field(description="Customer name")
total_cents: int = Field(description="Order total in cents, so 1250 means 12.50")
status: Literal["paid", "shipped", "delivered"] = Field(description="Current order status")


class OrderPage(BaseModel):
orders: list[Order] = Field(description="Orders on this page, oldest first")
next_cursor: str | None = Field(
description="Pass this back as cursor to get the next page. Null on the last page."
)

Field(description=...) puts each description into the schema that clients receive, so the model sees what next_cursor means before it calls the tool.

Python turns the return annotation into the schema like this:

  • A BaseModel, TypedDict or dataclass return becomes an object schema with those fields.
  • A scalar or list return, such as -> int, is wrapped as {"result": ...}. A list also reaches the model as one text block per item.
  • @mcp.tool(structured_output=False) makes a tool text-only.
  • A class with no type hints produces no schema, and the tool returns the object's repr as text without raising an error.

The schema is a contract. Python checks what your function returns against it before the result leaves the server, and a mismatch becomes a tool error, so the model sees a failure instead of a partial result. The TypeScript SDK checks structuredContent against outputSchema in the same way. The spec phrases this as a rule: servers MUST return structured results that conform to the schema, and clients SHOULD validate them.

2. Return text and structured content together​

The TypeScript handler writes the JSON text itself and returns the same object as structuredContent:

orders.py
@mcp.tool()
def get_order(
order_id: Annotated[str, Field(description="Order ID, such as ord_004")],
) -> Order:
"""Look up one order by its ID."""
for order in ORDERS:
if order.id == order_id:
return order
raise ToolError(f"No order with ID {order_id!r}. Use list_orders to find valid IDs.")

The handler returns an Order. The SDK sends that object as structuredContent and writes the same JSON into content.

A tool that fails doesn't return structuredContent. It returns isError: true with a message the model can read. The Python example raises ToolError, and the TypeScript example throws an Error. The TypeScript SDK skips the output schema check on error results. Tool errors covers what to put in those messages.

The wire encoding of structured results differs between the 2025 and 2026 protocol eras. The TypeScript SDK's protocol versions page lists the differences.

3. Page through long lists​

Two kinds of paging come up with MCP. The first is the protocol paging the tool list itself: tools/list takes a cursor and returns a nextCursor when there are more tools. The TypeScript client collects every page for you when you call listTools() without a cursor. The second kind is paging your tool's own data, which is what this section covers.

The spec makes cursors opaque. A client passes back the nextCursor it received, unchanged, and never builds one. The server chooses the page size, and a missing nextCursor means there are no more results.

Your tool can use the same idea. The list_orders tool takes a cursor and a limit, and returns next_cursor in its output. A null next_cursor marks the last page:

orders.py
@mcp.tool()
def list_orders(
cursor: Annotated[
str | None,
Field(description="Copy next_cursor from the previous page here. Leave out for the first page."),
] = None,
limit: Annotated[
int, Field(ge=1, le=MAX_PAGE_SIZE, description="Number of orders to return")
] = 5,
) -> OrderPage:
"""List orders a page at a time, oldest first."""
start = 0
if cursor is not None:
try:
start = int(cursor)
except ValueError:
start = -1
if not 0 <= start < len(ORDERS):
raise ToolError(f"Invalid cursor {cursor!r}. Pass next_cursor from the last page as is.")

end = start + limit
next_cursor = str(end) if end < len(ORDERS) else None
return OrderPage(orders=ORDERS[start:end], next_cursor=next_cursor)

Asking for cursor set to 10 and limit set to 5 returns the last two orders, ord_011 and ord_012, with next_cursor set to null. The example uses an offset as the cursor because the data doesn't change between calls. If rows can be added or removed between pages, a cursor built from the last ID you returned avoids skipped or repeated rows.

The schema types cursor as a string, so send it as a string. With the Inspector, --tool-arg cursor=10 sends the number 10, and the Python server rejects it. Use --tool-args-json '{"cursor":"10"}' instead.

The tools section of the spec doesn't set a size limit on results. Clients do set limits, though, and a long result takes up space in the model's context. The pagination page of the spec notes that paging helps with large data sets, both for remote services and for local integrations.

Claude Code is one client with concrete limits. Its MCP output limits work like this:

LimitWhat Claude Code does
Output over 10,000 tokensShows a warning
Output over 25,000 tokens (default for MAX_MCP_OUTPUT_TOKENS)Saves the result to a file and gives the model the path
Text over 50,000 charactersSaves the result to a file, unless the tool sets anthropic/maxResultSizeChars in its _meta, which can raise the limit to 500,000 characters
HTTP or SSE response over 16 MiBThe request fails

Other clients set their own limits, so check the ones you target. Either way, the fix is to return less per call. Page the data as in section 3, or return a resource_link and let the model fetch the data by URI when it needs it.

The example's export_orders tool does the second. It returns a short text block and a resource_link, and the CSV lives in a resource:

orders.py
@mcp.tool()
def export_orders() -> list[TextContent | ResourceLink]:
"""Export every order as CSV. Returns a link to the CSV rather than the CSV itself."""
logger.info("Export requested for %d orders", len(ORDERS))
return [
TextContent(
type="text",
text=f"Exported {len(ORDERS)} orders. Read orders://export/all.csv for the file.",
),
ResourceLink(
name="all-orders.csv",
uri="orders://export/all.csv",
mime_type="text/csv",
),
]

orders.py
@mcp.resource("orders://export/all.csv", mime_type="text/csv")
def orders_csv() -> str:
"""Every order as CSV."""
rows = [f"{o.id},{o.customer},{o.total_cents},{o.status}" for o in ORDERS]
return "\n".join(["id,customer,total_cents,status", *rows])

The resource_link names a URI and carries none of the data. A client reads the data with a resources/read request for that URI. The spec says resource links returned by tools aren't guaranteed to appear in resources/list, so share the URI and don't rely on the list. Against the example server, resources/read on orders://export/all.csv returns all twelve rows.

Try the example​

Both servers are complete and runnable. Start with the Python server from the python folder:

npx -y @modelcontextprotocol/inspector@2.11.0 --cli uv run orders.py --protocol-era modern --method tools/list
npx -y @modelcontextprotocol/inspector@2.11.0 --cli uv run orders.py --protocol-era modern --method tools/call --tool-name get_order --tool-arg order_id=ord_004

The TypeScript server runs the same way from the typescript folder, after npm install and npm run build:

npx -y @modelcontextprotocol/inspector@2.11.0 --cli node build/index.js --protocol-era modern --method tools/list
npx -y @modelcontextprotocol/inspector@2.11.0 --cli node build/index.js --protocol-era modern --method tools/call --tool-name list_orders --tool-args-json '{"cursor":"10","limit":5}'

The Inspector's --protocol-era modern checks the 2026-07-28 revision, and --protocol-era legacy checks the 2025-era handshake. Run tools/list in both to see the same tools and outputSchema in each.

The complete files are below.

orders.py
import logging
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ResourceLink, TextContent

# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")

MAX_PAGE_SIZE = 10


class Order(BaseModel):
id: str = Field(description="Order ID, such as ord_004")
customer: str = Field(description="Customer name")
total_cents: int = Field(description="Order total in cents, so 1250 means 12.50")
status: Literal["paid", "shipped", "delivered"] = Field(description="Current order status")


class OrderPage(BaseModel):
orders: list[Order] = Field(description="Orders on this page, oldest first")
next_cursor: str | None = Field(
description="Pass this back as cursor to get the next page. Null on the last page."
)


STATUSES = ["paid", "shipped", "delivered"]
ORDERS: list[Order] = [
Order(
id=f"ord_{n:03d}",
customer=f"Acme customer {n}",
total_cents=1000 + n * 250,
status=STATUSES[n % 3],
)
for n in range(1, 13)
]

mcp = MCPServer("orders")


@mcp.tool()
def get_order(
order_id: Annotated[str, Field(description="Order ID, such as ord_004")],
) -> Order:
"""Look up one order by its ID."""
for order in ORDERS:
if order.id == order_id:
return order
raise ToolError(f"No order with ID {order_id!r}. Use list_orders to find valid IDs.")


@mcp.tool()
def list_orders(
cursor: Annotated[
str | None,
Field(description="Copy next_cursor from the previous page here. Leave out for the first page."),
] = None,
limit: Annotated[
int, Field(ge=1, le=MAX_PAGE_SIZE, description="Number of orders to return")
] = 5,
) -> OrderPage:
"""List orders a page at a time, oldest first."""
start = 0
if cursor is not None:
try:
start = int(cursor)
except ValueError:
start = -1
if not 0 <= start < len(ORDERS):
raise ToolError(f"Invalid cursor {cursor!r}. Pass next_cursor from the last page as is.")

end = start + limit
next_cursor = str(end) if end < len(ORDERS) else None
return OrderPage(orders=ORDERS[start:end], next_cursor=next_cursor)


@mcp.tool()
def export_orders() -> list[TextContent | ResourceLink]:
"""Export every order as CSV. Returns a link to the CSV rather than the CSV itself."""
logger.info("Export requested for %d orders", len(ORDERS))
return [
TextContent(
type="text",
text=f"Exported {len(ORDERS)} orders. Read orders://export/all.csv for the file.",
),
ResourceLink(
name="all-orders.csv",
uri="orders://export/all.csv",
mime_type="text/csv",
),
]


@mcp.resource("orders://export/all.csv", mime_type="text/csv")
def orders_csv() -> str:
"""Every order as CSV."""
rows = [f"{o.id},{o.customer},{o.total_cents},{o.status}" for o in ORDERS]
return "\n".join(["id,customer,total_cents,status", *rows])


if __name__ == "__main__":
mcp.run(transport="stdio")

Common problems​

Input should be a valid string from list_orders. The cursor argument arrived as a number, because the Inspector sent cursor=10 as a JSON number. Pass the next_cursor you received as a string, and with the Inspector use --tool-args-json.

A Python tool that returns a string gives your app a string inside a JSON object. This tool returns every order as one JSON string:

@mcp.tool()
def list_all_orders() -> str:
"""List every order."""
return json.dumps([order.model_dump() for order in ORDERS])

Python wraps the str return as {"result": ...}, so structuredContent holds the whole list as one string. Your app has to parse that string again, and the result grows with every order. Return an OrderPage as the list_orders tool does, and paging limits the size.

outputSchema is only documentation. It isn't. The spec says servers MUST return results that conform to it. Both SDKs check the result before it leaves the server, and the Python SDK turns a mismatch into a tool error.

The Inspector's --strict check warns about next_cursor in the TypeScript server. Zod writes .nullable() as a two-item type array, ["string","null"], which the check calls less portable. It's a warning, not an error, so the example keeps it. If your target clients are strict about schemas, express the field as an anyOf instead. The Python server passes --strict with no warnings.

A resource link doesn't appear in resources/list. The spec doesn't guarantee that. Read the linked URI with resources/read.

FAQ​

What is structuredContent in MCP?

structuredContent is a JSON value a tool can return next to its content. The model reads content, and your application reads the fields in structuredContent. The tool may also declare an outputSchema that describes that value.

Does an MCP tool need an outputSchema?

No. The spec lists outputSchema as optional. Declare one when your application reads fields from the result, because the schema tells clients what to expect and the SDKs check each structured result against it before it is sent.

How big can an MCP tool result be?

The spec does not set a size limit for tool results, but clients do. Claude Code, for example, warns above 10,000 tokens and saves text results longer than 50,000 characters to a file. Return one page at a time, or return a resource link for large data.

Should an MCP tool return text or JSON?

Return both when the result has fields a program will read, so the model gets readable text and your app gets structured data. Return text alone when the answer is prose or a short status message.