Summary
createMcpServer() sends every result twice when the reflected function has a return type: once serialized into a text content block, and once as structuredContent. A client that caps tool-result size counts both copies, so a tool whose payload is well inside the cap is rejected at double its real size.
packages/mcp/src/internal/McpControllerRegistrar.ts:
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
// When the reflected return type schema exists, ship the result as
// structured output too; the text block above stays as the
// spec-recommended fallback for clients that ignore `outputSchema`.
...(entry.function.output !== undefined && ...
? { structuredContent: result as Record<string, unknown> }
: {}),
};
The comment is right about the spec — the text block is recommended (SHOULD), not required — but there is no way to turn it off, and for a tool that returns a large object the fallback is what breaks the tool.
What it cost us
@ttsc/graph serves one MCP tool that answers a code question from a compiler-built graph. A typical answer (a repository "tour") is a 30 KB JSON object — comfortably inside Claude Code's tool-result budget.
With createMcpServer, it arrives as ~60 KB. Claude Code rejects it:
Error: result (64,076 characters) exceeds maximum allowed tokens.
Output has been saved to <file>
and hands the model a file path instead of the answer. The model then shells out to read its own tool result back from disk — one benchmark cell spent 14 extra shell/file reads and 2.7M tokens recovering a result the server had already computed, against ~300k for the same question when the payload fits.
Halving the payload (one copy on the wire) puts every one of our fixtures back inside the cap and the model answers from a single call again.
What we had to do
We dropped createMcpServer and registered the tool by hand against the SDK's low-level Server, reusing typia.llm.application<T>() for the input schema, the output schema, and the validator — everything @typia/mcp gives us except the response shape:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const validation = typia.validate<IProps>(request.params.arguments ?? {});
if (!validation.success) return { isError: true, content: [{ type: "text", text: JSON.stringify(validation.errors) }] };
const output = await execute.inspect_typescript_graph(validation.data);
return { content: [], structuredContent: output }; // the schema is advertised; the payload crosses once
});
That is a lot of protocol plumbing to re-own for one line of behavior, and it means we no longer benefit from @typia/mcp as the framework it wants to be (#1989).
Proposal
Make the text fallback opt-out, e.g.
createMcpServer(controller, version, { textFallback: false })
// or: { content: "structured" | "text" | "both" } (default "both", today's behavior)
Servers whose results are small keep the compatibility fallback; servers whose results are large — which is exactly the case a structured output schema exists for — can send the payload once and stay inside the client's budget.
Happy to send the PR if you want it in this shape.
Summary
createMcpServer()sends every result twice when the reflected function has a return type: once serialized into atextcontent block, and once asstructuredContent. A client that caps tool-result size counts both copies, so a tool whose payload is well inside the cap is rejected at double its real size.packages/mcp/src/internal/McpControllerRegistrar.ts:The comment is right about the spec — the text block is recommended (
SHOULD), not required — but there is no way to turn it off, and for a tool that returns a large object the fallback is what breaks the tool.What it cost us
@ttsc/graphserves one MCP tool that answers a code question from a compiler-built graph. A typical answer (a repository "tour") is a 30 KB JSON object — comfortably inside Claude Code's tool-result budget.With
createMcpServer, it arrives as ~60 KB. Claude Code rejects it:and hands the model a file path instead of the answer. The model then shells out to read its own tool result back from disk — one benchmark cell spent 14 extra shell/file reads and 2.7M tokens recovering a result the server had already computed, against ~300k for the same question when the payload fits.
Halving the payload (one copy on the wire) puts every one of our fixtures back inside the cap and the model answers from a single call again.
What we had to do
We dropped
createMcpServerand registered the tool by hand against the SDK's low-levelServer, reusingtypia.llm.application<T>()for the input schema, the output schema, and the validator — everything@typia/mcpgives us except the response shape:That is a lot of protocol plumbing to re-own for one line of behavior, and it means we no longer benefit from
@typia/mcpas the framework it wants to be (#1989).Proposal
Make the text fallback opt-out, e.g.
Servers whose results are small keep the compatibility fallback; servers whose results are large — which is exactly the case a structured output schema exists for — can send the payload once and stay inside the client's budget.
Happy to send the PR if you want it in this shape.