From 65f08cfdc57c24682866a23a4213ed47dd9d8898 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:09:04 +0900 Subject: [PATCH 1/6] feat(mcp): add textFallback option to skip duplicate text copy of structured results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMcpServer sent every typed tool result twice — once as a serialized JSON text block and once as structuredContent. Clients that cap tool-result size count both copies, so a payload well inside the cap was rejected at double its real size. The new IMcpServerOptions.textFallback (default true) lets a server opt out of the duplicate text copy; results with no structured representation (void methods, validation and runtime errors) keep their text content regardless. Fixes #2020 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- packages/mcp/README.md | 8 ++- packages/mcp/src/index.ts | 27 +++++++- .../src/internal/McpControllerRegistrar.ts | 26 +++++--- .../test_mcp_tool_text_fallback_disabled.ts | 66 +++++++++++++++++++ ...tool_text_fallback_disabled_void_result.ts | 52 +++++++++++++++ website/src/content/docs/utilization/mcp.mdx | 17 +++++ 6 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts create mode 100644 tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 68c2a6b167..74c0a4ea5f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -27,7 +27,7 @@ npx typia setup ## Usage -`createMcpServer(controller, version?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. +`createMcpServer(controller, version?, options?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. ```typescript import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -48,6 +48,12 @@ const server = createMcpServer( await server.connect(new StdioServerTransport()); ``` +Every typed tool result ships as `structuredContent` plus a serialized-JSON text block, the spec-recommended fallback for clients that ignore `outputSchema`. That doubles the payload on the wire, so a client that caps tool-result size may reject a large result that actually fits. Pass `{ textFallback: false }` to ship each structured result once; results with no structured representation (`void` methods, errors) keep their text content regardless. + +```typescript +const server = createMcpServer(controller, "1.0.0", { textFallback: false }); +``` + ## Validation feedback If the LLM provides invalid arguments, the tool returns the input annotated with `// ❌` markers so the model can self-correct: diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 0b23c0fcb1..e03b7a0952 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -3,6 +3,25 @@ import { ILlmController } from "@typia/interface"; import { McpControllerRegistrar } from "./internal/McpControllerRegistrar"; +/** Options of {@link createMcpServer}. */ +export interface IMcpServerOptions { + /** + * Whether to keep the serialized JSON text block next to `structuredContent` + * in every tool result. + * + * The MCP spec recommends the text copy as a fallback for clients that ignore + * `outputSchema`, so it stays on by default. Turning it off ships each + * structured result once — with the fallback, a tool result crosses the wire + * twice, and a client that caps tool-result size counts both copies, + * rejecting payloads at double their real size. A result that has no + * structured representation (a `void` method, a validation failure, a runtime + * error) keeps its text content regardless of this option. + * + * @default true + */ + textFallback?: boolean | undefined; +} + /** * Create an MCP server over a single typia controller. * @@ -43,11 +62,13 @@ import { McpControllerRegistrar } from "./internal/McpControllerRegistrar"; * @template Class Executor class type of the controller * @param controller Controller from `typia.llm.controller()` * @param version Server version for the MCP handshake + * @param options Optional behaviors of the server ({@link IMcpServerOptions}) * @returns McpServer ready to connect to a transport */ export function createMcpServer( controller: ILlmController, version: string = "1.0.0", + options?: IMcpServerOptions, ): McpServer { const instructions: string | undefined = controller.application.description?.trim() || undefined; @@ -58,6 +79,10 @@ export function createMcpServer( ...(instructions !== undefined ? { instructions } : {}), }, ); - McpControllerRegistrar.register(server.server, controller); + McpControllerRegistrar.register( + server.server, + controller, + options?.textFallback !== false, + ); return server; } diff --git a/packages/mcp/src/internal/McpControllerRegistrar.ts b/packages/mcp/src/internal/McpControllerRegistrar.ts index d7389c8953..913d5359b8 100644 --- a/packages/mcp/src/internal/McpControllerRegistrar.ts +++ b/packages/mcp/src/internal/McpControllerRegistrar.ts @@ -19,6 +19,7 @@ export namespace McpControllerRegistrar { export const register = ( server: Server, controller: ILlmController, + textFallback: boolean, ): void => { // Build tool registry from the controller's functions const registry: Map = new Map(); @@ -68,13 +69,14 @@ export namespace McpControllerRegistrar { `Unknown tool: ${request.params.name}`, ); } - return handleToolCall(entry, request.params.arguments); + return handleToolCall(entry, request.params.arguments, textFallback); }); }; const handleToolCall = async ( entry: IToolEntry, args: unknown, + textFallback: boolean, ): Promise => { // Validate an empty object when a client omits `arguments` — the MCP spec // allows the omission for zero-parameter tools, and validating `{}` also @@ -97,15 +99,23 @@ export namespace McpControllerRegistrar { if (result === undefined) { return { content: [{ type: "text" as const, text: "Success" }] }; } - 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 && + // When the reflected return type schema exists, ship the result as + // structured output; the text block is the spec-recommended fallback + // for clients that ignore `outputSchema`, and `textFallback: false` + // drops that duplicate copy. A result that cannot ship as + // `structuredContent` keeps its text block regardless — dropping it + // would leave the call with no payload at all. + const structured: boolean = + entry.function.output !== undefined && typeof result === "object" && result !== null && - !Array.isArray(result) + !Array.isArray(result); + return { + content: + structured && textFallback === false + ? [] + : [{ type: "text" as const, text: JSON.stringify(result) }], + ...(structured ? { structuredContent: result as Record } : {}), }; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts new file mode 100644 index 0000000000..71d41dd9c8 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts @@ -0,0 +1,66 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { TestValidator } from "@nestia/e2e"; +import { createMcpServer } from "@typia/mcp"; +import typia from "typia"; + +import { Calculator } from "../structures/Calculator"; + +/** + * Verifies `textFallback: false` ships a structured result exactly once. + * + * By default a typed tool result crosses the wire twice — once as the + * serialized text fallback and once as `structuredContent` — and a client that + * caps tool-result size counts both copies, rejecting a payload at double its + * real size. The opt-out must drop the text block while `structuredContent` + * still carries the typed result, and must leave `outputSchema` advertisement + * untouched. + * + * 1. Serve `Calculator` with `textFallback: false`. + * 2. Assert `tools/list` still advertises `outputSchema` for `add`. + * 3. Call `add` and assert `structuredContent` carries the typed result while + * `content` is empty. + */ +export const test_mcp_tool_text_fallback_disabled = async (): Promise => { + const server: McpServer = createMcpServer( + typia.llm.controller("calculator", new Calculator()), + "1.0.0", + { textFallback: false }, + ); + + const rawServer: Server = server.server; + const requestHandlers: Map = (rawServer as any) + ._requestHandlers; + + const listHandler: Function = requestHandlers.get("tools/list")!; + const listed: { tools: Array<{ name: string; outputSchema?: unknown }> } = + await listHandler( + { method: "tools/list", params: {} }, + { signal: new AbortController().signal }, + ); + TestValidator.predicate( + "add tool should still advertise outputSchema", + listed.tools.find((tool) => tool.name === "add")?.outputSchema !== + undefined, + ); + + const callHandler: Function = requestHandlers.get("tools/call")!; + const result: CallToolResult = await callHandler( + { + method: "tools/call", + params: { name: "add", arguments: { x: 10, y: 5 } }, + }, + { signal: new AbortController().signal }, + ); + TestValidator.equals( + "structuredContent should carry the typed result", + result.structuredContent, + { value: 15 }, + ); + TestValidator.equals( + "content should omit the text fallback", + result.content, + [], + ); +}; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts new file mode 100644 index 0000000000..3bf222daf1 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts @@ -0,0 +1,52 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { TestValidator } from "@nestia/e2e"; +import { createMcpServer } from "@typia/mcp"; +import typia from "typia"; + +import { Greeter } from "../structures/Greeter"; + +/** + * Verifies `textFallback: false` keeps the text block when nothing is + * structured. + * + * The opt-out only suppresses the _duplicate_ copy of a result that also ships + * as `structuredContent`. A `void`-returning method has no structured + * representation, so its `"Success"` text is the sole payload — dropping it + * under `textFallback: false` would return an empty, spec-invalid result. + * + * 1. Serve `Greeter.reset()`, a `void`-returning method, with `textFallback: + * false`. + * 2. Call it through `tools/call`. + * 3. Assert the result still reports `"Success"` as text and carries no + * `structuredContent`. + */ +export const test_mcp_tool_text_fallback_disabled_void_result = + async (): Promise => { + const server: McpServer = createMcpServer( + typia.llm.controller("greeter", new Greeter()), + "1.0.0", + { textFallback: false }, + ); + + const rawServer: Server = server.server; + const callHandler: Function = (rawServer as any)._requestHandlers.get( + "tools/call", + )!; + + const result: CallToolResult = await callHandler( + { method: "tools/call", params: { name: "reset" } }, + { signal: new AbortController().signal }, + ); + + TestValidator.equals( + "void result should keep Success as text", + (result.content[0] as { text: string }).text, + "Success", + ); + TestValidator.predicate( + "void result should carry no structuredContent", + result.structuredContent === undefined, + ); + }; diff --git a/website/src/content/docs/utilization/mcp.mdx b/website/src/content/docs/utilization/mcp.mdx index 50f2f14e44..6462511a5c 100644 --- a/website/src/content/docs/utilization/mcp.mdx +++ b/website/src/content/docs/utilization/mcp.mdx @@ -17,7 +17,12 @@ import { createMcpServer } from "@typia/mcp"; export function createMcpServer( controller: ILlmController, version?: string, // default "1.0.0" + options?: IMcpServerOptions, ): McpServer; + +export interface IMcpServerOptions { + textFallback?: boolean; // default true +} ``` The class you would hand to [`typia.llm.application()`](/docs/llm/application) _is_ your server. Its methods become the tools, each method's JSDoc becomes that tool's description, and — the part most people miss — the JSDoc written on the class (or interface) itself becomes the server's handshake [instructions](#server-instructions). You pass the class through [`typia.llm.controller(name, instance)`](/docs/llm/application#framework-adapters) — the same reflected application bound to a live instance so the server can execute the calls — where `name` becomes the server name. The returned server connects to any MCP transport: @@ -135,6 +140,18 @@ When a method's return type is reflected (`ILlmFunction.output`), the tool adver as its `outputSchema`, and a call returns `structuredContent: { value: 15 }` next to the plain-text block so clients that understand structured output get the typed object and the rest still see the text. +The text block is a *fallback*: the MCP spec recommends it for clients that ignore `outputSchema`, but it means every structured result crosses the wire twice. A client that caps tool-result size counts both copies, so a tool returning a large object can be rejected at double its real size even though the payload itself fits the cap. Pass `textFallback: false` to ship each structured result once: + +```typescript filename="src/main.ts" showLineNumbers {4} +const server = createMcpServer( + typia.llm.controller("bbs", new BbsArticleService()), + "1.0.0", + { textFallback: false }, +); +``` + +The opt-out only suppresses the duplicate copy. A result with no structured representation — a `void` method's `"Success"`, a validation failure, a runtime error — keeps its text content regardless. + ## Instructions vs. runtime validation Every tool call runs typia's lenient JSON parsing, type coercion, and validation — the same harness as [`typia.llm.application`](/docs/llm/application). When validation fails, the tool returns the input annotated with `// ❌` markers as an in-band tool error, which the LLM reads and self-corrects from — the feedback loop the MCP spec recommends for input validation errors: From 111542a94549b7606a1fef8f384e2500de253733 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:15:20 +0900 Subject: [PATCH 2/6] test(mcp): use SDK Tool type in text-fallback test for peer consistency Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- .../test_mcp_tool_text_fallback_disabled.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts index 71d41dd9c8..b2ccaac5aa 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts @@ -1,6 +1,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { TestValidator } from "@nestia/e2e"; import { createMcpServer } from "@typia/mcp"; import typia from "typia"; @@ -34,14 +34,13 @@ export const test_mcp_tool_text_fallback_disabled = async (): Promise => { ._requestHandlers; const listHandler: Function = requestHandlers.get("tools/list")!; - const listed: { tools: Array<{ name: string; outputSchema?: unknown }> } = - await listHandler( - { method: "tools/list", params: {} }, - { signal: new AbortController().signal }, - ); + const listed: { tools: Tool[] } = await listHandler( + { method: "tools/list", params: {} }, + { signal: new AbortController().signal }, + ); TestValidator.predicate( "add tool should still advertise outputSchema", - listed.tools.find((tool) => tool.name === "add")?.outputSchema !== + listed.tools.find((tool: Tool) => tool.name === "add")?.outputSchema !== undefined, ); From a572d49c879e27f8e29a39dcc654030c62a14607 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:25:40 +0900 Subject: [PATCH 3/6] refactor(mcp): fold handshake version into IMcpServerOptions Two trailing optional positional parameters forced callers to spell out the "1.0.0" default just to reach the options bag. The required controller stays positional and everything optional lives in IMcpServerOptions, matching the (controller, options?) shape of toVercelTools and toLangChainTools. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- packages/mcp/README.md | 4 +- packages/mcp/src/index.ts | 11 ++++-- .../test_mcp_create_server_version.ts | 39 +++++++++++++++++++ .../test_mcp_tool_text_fallback_disabled.ts | 1 - ...tool_text_fallback_disabled_void_result.ts | 1 - website/src/content/docs/utilization/mcp.mdx | 5 +-- 6 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 tests/test-mcp/src/features/test_mcp_create_server_version.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 74c0a4ea5f..7e51f3a58d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -27,7 +27,7 @@ npx typia setup ## Usage -`createMcpServer(controller, version?, options?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. +`createMcpServer(controller, options?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. The options carry the handshake `version` (default `"1.0.0"`) and the behaviors below. ```typescript import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -51,7 +51,7 @@ await server.connect(new StdioServerTransport()); Every typed tool result ships as `structuredContent` plus a serialized-JSON text block, the spec-recommended fallback for clients that ignore `outputSchema`. That doubles the payload on the wire, so a client that caps tool-result size may reject a large result that actually fits. Pass `{ textFallback: false }` to ship each structured result once; results with no structured representation (`void` methods, errors) keep their text content regardless. ```typescript -const server = createMcpServer(controller, "1.0.0", { textFallback: false }); +const server = createMcpServer(controller, { textFallback: false }); ``` ## Validation feedback diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e03b7a0952..488fa58420 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -5,6 +5,13 @@ import { McpControllerRegistrar } from "./internal/McpControllerRegistrar"; /** Options of {@link createMcpServer}. */ export interface IMcpServerOptions { + /** + * Server version for the MCP handshake. + * + * @default "1.0.0" + */ + version?: string | undefined; + /** * Whether to keep the serialized JSON text block next to `structuredContent` * in every tool result. @@ -61,19 +68,17 @@ export interface IMcpServerOptions { * * @template Class Executor class type of the controller * @param controller Controller from `typia.llm.controller()` - * @param version Server version for the MCP handshake * @param options Optional behaviors of the server ({@link IMcpServerOptions}) * @returns McpServer ready to connect to a transport */ export function createMcpServer( controller: ILlmController, - version: string = "1.0.0", options?: IMcpServerOptions, ): McpServer { const instructions: string | undefined = controller.application.description?.trim() || undefined; const server: McpServer = new McpServer( - { name: controller.name, version }, + { name: controller.name, version: options?.version ?? "1.0.0" }, { capabilities: { tools: {} }, ...(instructions !== undefined ? { instructions } : {}), diff --git a/tests/test-mcp/src/features/test_mcp_create_server_version.ts b/tests/test-mcp/src/features/test_mcp_create_server_version.ts new file mode 100644 index 0000000000..e81720d775 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_create_server_version.ts @@ -0,0 +1,39 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { TestValidator } from "@nestia/e2e"; +import { createMcpServer } from "@typia/mcp"; +import typia from "typia"; + +import { Greeter } from "../structures/Greeter"; + +/** + * Verifies `options.version` reaches the MCP handshake `serverInfo`. + * + * The server version lives in {@link IMcpServerOptions} next to the other + * optional behaviors, and it must land on the `Implementation` the SDK + * announces during initialization. A regression would pin every server to the + * `"1.0.0"` default no matter what the caller declares. + * + * 1. Create a server with `{ version: "2.5.0" }`. + * 2. Assert the underlying SDK server's `serverInfo.version` is `"2.5.0"`. + * 3. Create a server without options and assert the `"1.0.0"` default. + */ +export const test_mcp_create_server_version = async (): Promise => { + const explicit: McpServer = createMcpServer( + typia.llm.controller("greeter", new Greeter()), + { version: "2.5.0" }, + ); + TestValidator.equals( + "explicit version should reach serverInfo", + ((explicit.server as any)._serverInfo as { version: string }).version, + "2.5.0", + ); + + const defaulted: McpServer = createMcpServer( + typia.llm.controller("greeter", new Greeter()), + ); + TestValidator.equals( + "omitted version should default to 1.0.0", + ((defaulted.server as any)._serverInfo as { version: string }).version, + "1.0.0", + ); +}; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts index b2ccaac5aa..b7b29b3709 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts @@ -25,7 +25,6 @@ import { Calculator } from "../structures/Calculator"; export const test_mcp_tool_text_fallback_disabled = async (): Promise => { const server: McpServer = createMcpServer( typia.llm.controller("calculator", new Calculator()), - "1.0.0", { textFallback: false }, ); diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts index 3bf222daf1..30e7d9e0ba 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts @@ -26,7 +26,6 @@ export const test_mcp_tool_text_fallback_disabled_void_result = async (): Promise => { const server: McpServer = createMcpServer( typia.llm.controller("greeter", new Greeter()), - "1.0.0", { textFallback: false }, ); diff --git a/website/src/content/docs/utilization/mcp.mdx b/website/src/content/docs/utilization/mcp.mdx index 6462511a5c..662d8274f4 100644 --- a/website/src/content/docs/utilization/mcp.mdx +++ b/website/src/content/docs/utilization/mcp.mdx @@ -16,11 +16,11 @@ import { createMcpServer } from "@typia/mcp"; export function createMcpServer( controller: ILlmController, - version?: string, // default "1.0.0" options?: IMcpServerOptions, ): McpServer; export interface IMcpServerOptions { + version?: string; // default "1.0.0" textFallback?: boolean; // default true } ``` @@ -142,10 +142,9 @@ as its `outputSchema`, and a call returns `structuredContent: { value: 15 }` nex The text block is a *fallback*: the MCP spec recommends it for clients that ignore `outputSchema`, but it means every structured result crosses the wire twice. A client that caps tool-result size counts both copies, so a tool returning a large object can be rejected at double its real size even though the payload itself fits the cap. Pass `textFallback: false` to ship each structured result once: -```typescript filename="src/main.ts" showLineNumbers {4} +```typescript filename="src/main.ts" showLineNumbers {3} const server = createMcpServer( typia.llm.controller("bbs", new BbsArticleService()), - "1.0.0", { textFallback: false }, ); ``` From 506d16c0a53df92a34beb856a774b1049bfdeb04 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:31:18 +0900 Subject: [PATCH 4/6] feat(mcp): ship structured tool results once by default, text fallback opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's duplicate text copy is a backwards-compatibility SHOULD that doubles every typed result on the wire; size-capped clients count both copies and reject payloads at double their real size. textFallback now defaults to false — servers whose clients ignore outputSchema opt in with { textFallback: true }. Void results, validation failures, and runtime errors keep their text content as before. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- packages/mcp/README.md | 6 +- packages/mcp/src/index.ts | 22 +++---- .../src/internal/McpControllerRegistrar.ts | 13 ++-- ...st_mcp_tool_markdown_structured_content.ts | 12 ++-- .../features/test_mcp_tool_output_schema.ts | 15 +++-- .../test_mcp_tool_text_fallback_disabled.ts | 64 ------------------- ...tool_text_fallback_disabled_void_result.ts | 51 --------------- .../test_mcp_tool_text_fallback_enabled.ts | 51 +++++++++++++++ website/src/content/docs/utilization/mcp.mdx | 12 ++-- 9 files changed, 93 insertions(+), 153 deletions(-) delete mode 100644 tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts delete mode 100644 tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts create mode 100644 tests/test-mcp/src/features/test_mcp_tool_text_fallback_enabled.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 7e51f3a58d..ca1f2e430b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -48,12 +48,14 @@ const server = createMcpServer( await server.connect(new StdioServerTransport()); ``` -Every typed tool result ships as `structuredContent` plus a serialized-JSON text block, the spec-recommended fallback for clients that ignore `outputSchema`. That doubles the payload on the wire, so a client that caps tool-result size may reject a large result that actually fits. Pass `{ textFallback: false }` to ship each structured result once; results with no structured representation (`void` methods, errors) keep their text content regardless. +Every typed tool result ships once, as `structuredContent`. The MCP spec also recommends a duplicate serialized-JSON text block for clients that ignore `outputSchema` — but that doubles the payload, and a size-capped client counts both copies. So the fallback is opt-in: ```typescript -const server = createMcpServer(controller, { textFallback: false }); +const server = createMcpServer(controller, { textFallback: true }); ``` +Results with no structured representation (`void` methods, errors) always keep their text content. + ## Validation feedback If the LLM provides invalid arguments, the tool returns the input annotated with `// ❌` markers so the model can self-correct: diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 488fa58420..ea71b029b6 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -13,18 +13,18 @@ export interface IMcpServerOptions { version?: string | undefined; /** - * Whether to keep the serialized JSON text block next to `structuredContent` - * in every tool result. + * Whether to add a serialized JSON text block next to `structuredContent` in + * every tool result. * - * The MCP spec recommends the text copy as a fallback for clients that ignore - * `outputSchema`, so it stays on by default. Turning it off ships each - * structured result once — with the fallback, a tool result crosses the wire - * twice, and a client that caps tool-result size counts both copies, - * rejecting payloads at double their real size. A result that has no - * structured representation (a `void` method, a validation failure, a runtime - * error) keeps its text content regardless of this option. + * The MCP spec recommends the duplicate text copy as a fallback for clients + * that ignore `outputSchema`. But it doubles the payload, and a client that + * caps tool-result size counts both copies — so structured results ship once + * by default, and the fallback is an opt-in. * - * @default true + * A result with no structured representation (a `void` method, a validation + * failure, a runtime error) always keeps its text content. + * + * @default false */ textFallback?: boolean | undefined; } @@ -87,7 +87,7 @@ export function createMcpServer( McpControllerRegistrar.register( server.server, controller, - options?.textFallback !== false, + options?.textFallback === true, ); return server; } diff --git a/packages/mcp/src/internal/McpControllerRegistrar.ts b/packages/mcp/src/internal/McpControllerRegistrar.ts index 913d5359b8..d801d41293 100644 --- a/packages/mcp/src/internal/McpControllerRegistrar.ts +++ b/packages/mcp/src/internal/McpControllerRegistrar.ts @@ -100,11 +100,12 @@ export namespace McpControllerRegistrar { return { content: [{ type: "text" as const, text: "Success" }] }; } // When the reflected return type schema exists, ship the result as - // structured output; the text block is the spec-recommended fallback - // for clients that ignore `outputSchema`, and `textFallback: false` - // drops that duplicate copy. A result that cannot ship as - // `structuredContent` keeps its text block regardless — dropping it - // would leave the call with no payload at all. + // structured output, once. The spec's duplicate text fallback for + // clients that ignore `outputSchema` doubles the payload, so it is + // opt-in through `textFallback: true`. + // + // A result that cannot ship as `structuredContent` keeps its text + // block regardless — dropping it would leave the call empty. const structured: boolean = entry.function.output !== undefined && typeof result === "object" && @@ -112,7 +113,7 @@ export namespace McpControllerRegistrar { !Array.isArray(result); return { content: - structured && textFallback === false + structured && !textFallback ? [] : [{ type: "text" as const, text: JSON.stringify(result) }], ...(structured diff --git a/tests/test-mcp/src/features/test_mcp_tool_markdown_structured_content.ts b/tests/test-mcp/src/features/test_mcp_tool_markdown_structured_content.ts index 413a9557a7..b7d5bf4752 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_markdown_structured_content.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_markdown_structured_content.ts @@ -13,12 +13,12 @@ import { TicketSearch } from "../structures/TicketSearch"; * * Locks the MCP structured-output path for controller methods that wrap raw * Markdown in an object to satisfy typia.llm's object-return rule. A regression - * would leave clients with only an escaped JSON text fallback. + * would leave clients with only an escaped JSON text rendering. * * 1. Serve a ticket search controller whose result object contains Markdown. * 2. Call `searchTickets` through tools/call. - * 3. Assert `structuredContent` carries the original object while text remains the - * backward-compatible serialized JSON fallback. + * 3. Assert `structuredContent` carries the original object and no text copy + * accompanies it by default. */ export const test_mcp_tool_markdown_structured_content = async (): Promise => { @@ -47,8 +47,8 @@ export const test_mcp_tool_markdown_structured_content = { content: markdown }, ); TestValidator.equals( - "text content should remain the JSON fallback", - JSON.parse((result.content[0] as { text: string }).text), - { content: markdown }, + "content should stay empty without the opt-in text fallback", + result.content, + [], ); }; diff --git a/tests/test-mcp/src/features/test_mcp_tool_output_schema.ts b/tests/test-mcp/src/features/test_mcp_tool_output_schema.ts index 3a3f183843..58b8819f7e 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_output_schema.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_output_schema.ts @@ -13,13 +13,14 @@ import { Calculator } from "../structures/Calculator"; * * `ILlmFunction.output` reflects the method's return type, and MCP structured * output maps onto it directly: `outputSchema` in `tools/list` and - * `structuredContent` alongside the text fallback in `tools/call`. A regression - * would degrade every typed tool result back to opaque text. + * `structuredContent` in `tools/call`. By default the result crosses the wire + * once — no duplicate text copy. A regression would either degrade typed + * results back to opaque text or resurrect the double payload. * * 1. Serve `Calculator` whose methods return `IResult` objects. * 2. Assert `tools/list` advertises `outputSchema` with the `value` property. - * 3. Call `add` and assert `structuredContent` carries the typed result while the - * text block remains as fallback. + * 3. Call `add` and assert `structuredContent` carries the typed result while + * `content` stays empty. */ export const test_mcp_tool_output_schema = async (): Promise => { const server: McpServer = createMcpServer( @@ -60,8 +61,8 @@ export const test_mcp_tool_output_schema = async (): Promise => { { value: 15 }, ); TestValidator.equals( - "text content should remain as fallback", - JSON.parse((result.content[0] as { text: string }).text), - { value: 15 }, + "content should stay empty without the opt-in text fallback", + result.content, + [], ); }; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts deleted file mode 100644 index b7b29b3709..0000000000 --- a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; -import { TestValidator } from "@nestia/e2e"; -import { createMcpServer } from "@typia/mcp"; -import typia from "typia"; - -import { Calculator } from "../structures/Calculator"; - -/** - * Verifies `textFallback: false` ships a structured result exactly once. - * - * By default a typed tool result crosses the wire twice — once as the - * serialized text fallback and once as `structuredContent` — and a client that - * caps tool-result size counts both copies, rejecting a payload at double its - * real size. The opt-out must drop the text block while `structuredContent` - * still carries the typed result, and must leave `outputSchema` advertisement - * untouched. - * - * 1. Serve `Calculator` with `textFallback: false`. - * 2. Assert `tools/list` still advertises `outputSchema` for `add`. - * 3. Call `add` and assert `structuredContent` carries the typed result while - * `content` is empty. - */ -export const test_mcp_tool_text_fallback_disabled = async (): Promise => { - const server: McpServer = createMcpServer( - typia.llm.controller("calculator", new Calculator()), - { textFallback: false }, - ); - - const rawServer: Server = server.server; - const requestHandlers: Map = (rawServer as any) - ._requestHandlers; - - const listHandler: Function = requestHandlers.get("tools/list")!; - const listed: { tools: Tool[] } = await listHandler( - { method: "tools/list", params: {} }, - { signal: new AbortController().signal }, - ); - TestValidator.predicate( - "add tool should still advertise outputSchema", - listed.tools.find((tool: Tool) => tool.name === "add")?.outputSchema !== - undefined, - ); - - const callHandler: Function = requestHandlers.get("tools/call")!; - const result: CallToolResult = await callHandler( - { - method: "tools/call", - params: { name: "add", arguments: { x: 10, y: 5 } }, - }, - { signal: new AbortController().signal }, - ); - TestValidator.equals( - "structuredContent should carry the typed result", - result.structuredContent, - { value: 15 }, - ); - TestValidator.equals( - "content should omit the text fallback", - result.content, - [], - ); -}; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts deleted file mode 100644 index 30e7d9e0ba..0000000000 --- a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_disabled_void_result.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { TestValidator } from "@nestia/e2e"; -import { createMcpServer } from "@typia/mcp"; -import typia from "typia"; - -import { Greeter } from "../structures/Greeter"; - -/** - * Verifies `textFallback: false` keeps the text block when nothing is - * structured. - * - * The opt-out only suppresses the _duplicate_ copy of a result that also ships - * as `structuredContent`. A `void`-returning method has no structured - * representation, so its `"Success"` text is the sole payload — dropping it - * under `textFallback: false` would return an empty, spec-invalid result. - * - * 1. Serve `Greeter.reset()`, a `void`-returning method, with `textFallback: - * false`. - * 2. Call it through `tools/call`. - * 3. Assert the result still reports `"Success"` as text and carries no - * `structuredContent`. - */ -export const test_mcp_tool_text_fallback_disabled_void_result = - async (): Promise => { - const server: McpServer = createMcpServer( - typia.llm.controller("greeter", new Greeter()), - { textFallback: false }, - ); - - const rawServer: Server = server.server; - const callHandler: Function = (rawServer as any)._requestHandlers.get( - "tools/call", - )!; - - const result: CallToolResult = await callHandler( - { method: "tools/call", params: { name: "reset" } }, - { signal: new AbortController().signal }, - ); - - TestValidator.equals( - "void result should keep Success as text", - (result.content[0] as { text: string }).text, - "Success", - ); - TestValidator.predicate( - "void result should carry no structuredContent", - result.structuredContent === undefined, - ); - }; diff --git a/tests/test-mcp/src/features/test_mcp_tool_text_fallback_enabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_enabled.ts new file mode 100644 index 0000000000..1bb90dc791 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_enabled.ts @@ -0,0 +1,51 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { TestValidator } from "@nestia/e2e"; +import { createMcpServer } from "@typia/mcp"; +import typia from "typia"; + +import { Calculator } from "../structures/Calculator"; + +/** + * Verifies `textFallback: true` adds the serialized text copy next to + * `structuredContent`. + * + * Structured results ship once by default. The MCP spec's backwards + * compatibility recommendation — the same JSON serialized into a text block — + * is an opt-in for servers whose clients ignore `outputSchema`. A regression + * would strand those clients with an empty `content` array. + * + * 1. Serve `Calculator` with `textFallback: true`. + * 2. Call `add` through tools/call. + * 3. Assert `structuredContent` carries the typed result and the text block + * serializes the same object. + */ +export const test_mcp_tool_text_fallback_enabled = async (): Promise => { + const server: McpServer = createMcpServer( + typia.llm.controller("calculator", new Calculator()), + { textFallback: true }, + ); + + const rawServer: Server = server.server; + const callHandler: Function = (rawServer as any)._requestHandlers.get( + "tools/call", + )!; + const result: CallToolResult = await callHandler( + { + method: "tools/call", + params: { name: "add", arguments: { x: 10, y: 5 } }, + }, + { signal: new AbortController().signal }, + ); + TestValidator.equals( + "structuredContent should carry the typed result", + result.structuredContent, + { value: 15 }, + ); + TestValidator.equals( + "text block should serialize the same object", + JSON.parse((result.content[0] as { text: string }).text), + { value: 15 }, + ); +}; diff --git a/website/src/content/docs/utilization/mcp.mdx b/website/src/content/docs/utilization/mcp.mdx index 662d8274f4..903f5eec58 100644 --- a/website/src/content/docs/utilization/mcp.mdx +++ b/website/src/content/docs/utilization/mcp.mdx @@ -21,7 +21,7 @@ export function createMcpServer( export interface IMcpServerOptions { version?: string; // default "1.0.0" - textFallback?: boolean; // default true + textFallback?: boolean; // default false } ``` @@ -132,24 +132,24 @@ For a production example of instructions written entirely as interface JSDoc, se ### Structured output -When a method's return type is reflected (`ILlmFunction.output`), the tool advertises it as `outputSchema` in `tools/list`, and every call result ships as `structuredContent` alongside the JSON text fallback — MCP's structured tool output, for free, by construction. A `Calculator.add(props): { value: number }` tool therefore lists +When a method's return type is reflected (`ILlmFunction.output`), the tool advertises it as `outputSchema` in `tools/list`, and every call result ships as `structuredContent` — MCP's structured tool output, for free, by construction. A `Calculator.add(props): { value: number }` tool therefore lists ```json { "type": "object", "properties": { "value": { "type": "number" } }, "required": ["value"] } ``` -as its `outputSchema`, and a call returns `structuredContent: { value: 15 }` next to the plain-text block so clients that understand structured output get the typed object and the rest still see the text. +as its `outputSchema`, and a call returns `structuredContent: { value: 15 }` — once, with no duplicate rendering. -The text block is a *fallback*: the MCP spec recommends it for clients that ignore `outputSchema`, but it means every structured result crosses the wire twice. A client that caps tool-result size counts both copies, so a tool returning a large object can be rejected at double its real size even though the payload itself fits the cap. Pass `textFallback: false` to ship each structured result once: +The MCP spec also recommends serializing the same JSON into a text block, as a fallback for clients that ignore `outputSchema`. But that doubles every result on the wire, and a client that caps tool-result size counts both copies — a large result gets rejected at double its real size even though the payload itself fits. So the fallback is opt-in: ```typescript filename="src/main.ts" showLineNumbers {3} const server = createMcpServer( typia.llm.controller("bbs", new BbsArticleService()), - { textFallback: false }, + { textFallback: true }, ); ``` -The opt-out only suppresses the duplicate copy. A result with no structured representation — a `void` method's `"Success"`, a validation failure, a runtime error — keeps its text content regardless. +A result with no structured representation — a `void` method's `"Success"`, a validation failure, a runtime error — always keeps its text content. ## Instructions vs. runtime validation From 506c601a97b6ea98693122977a50cc88d61132fc Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:33:08 +0900 Subject: [PATCH 5/6] docs(mcp): split textFallback JSDoc into one idea per paragraph Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- packages/mcp/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index ea71b029b6..665a2bec29 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -18,8 +18,9 @@ export interface IMcpServerOptions { * * The MCP spec recommends the duplicate text copy as a fallback for clients * that ignore `outputSchema`. But it doubles the payload, and a client that - * caps tool-result size counts both copies — so structured results ship once - * by default, and the fallback is an opt-in. + * caps tool-result size counts both copies. + * + * So structured results ship once by default; opt in for legacy clients. * * A result with no structured representation (a `void` method, a validation * failure, a runtime error) always keeps its text content. From 59a9074c0c45f7235497370cf9028797ccfb7386 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 13 Jul 2026 01:56:51 +0900 Subject: [PATCH 6/6] feat(mcp): serve OpenAPI documents through createMcpServer, version from swagger createMcpServer now accepts IHttpLlmController: every OpenAPI operation becomes an MCP tool executing through the controller's executor or HttpLlm.execute, identical to the sibling vercel/langchain adapters. HttpLlm.application() preserves the document's info.version onto the new IHttpLlmApplication.version, and the MCP handshake announces it; a class controller stays at 1.0.0. IMcpServerOptions drops the version knob and carries only textFallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018HbtkuUSkLLuqmuibCFsac --- .../interface/src/http/IHttpLlmApplication.ts | 7 ++ packages/mcp/README.md | 3 +- packages/mcp/src/index.ts | 28 +++--- .../src/internal/McpControllerRegistrar.ts | 50 ++++++++--- .../internal/HttpLlmApplicationComposer.ts | 3 + .../test_mcp_class_controller_coercion.ts | 10 +-- .../test_mcp_class_controller_execute.ts | 14 +-- .../test_mcp_create_server_lazy_controller.ts | 3 +- .../test_mcp_create_server_version.ts | 31 +++---- .../test_mcp_http_controller_execute.ts | 85 +++++++++++++++++++ .../test_mcp_http_controller_version.ts | 35 ++++++++ .../test_mcp_tool_omitted_arguments.ts | 2 +- .../test-mcp/src/structures/CalculatorApi.ts | 58 +++++++++++++ .../http/test_http_llm_application_version.ts | 35 ++++++++ website/src/content/docs/utilization/mcp.mdx | 26 +++++- 15 files changed, 327 insertions(+), 63 deletions(-) create mode 100644 tests/test-mcp/src/features/test_mcp_http_controller_execute.ts create mode 100644 tests/test-mcp/src/features/test_mcp_http_controller_version.ts create mode 100644 tests/test-mcp/src/structures/CalculatorApi.ts create mode 100644 tests/test-utils/src/features/llm/http/test_http_llm_application_version.ts diff --git a/packages/interface/src/http/IHttpLlmApplication.ts b/packages/interface/src/http/IHttpLlmApplication.ts index 4b08057ddd..251ee74eee 100644 --- a/packages/interface/src/http/IHttpLlmApplication.ts +++ b/packages/interface/src/http/IHttpLlmApplication.ts @@ -33,6 +33,13 @@ export interface IHttpLlmApplication { /** Configuration used for composition. */ config: IHttpLlmApplication.IConfig; + + /** + * Version of the API, taken from the OpenAPI document's `info.version`. + * + * `undefined` when the source document carries no version info. + */ + version?: string | undefined; } export namespace IHttpLlmApplication { /** Configuration for HTTP LLM application composition. */ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ca1f2e430b..f4799b9e84 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -16,6 +16,7 @@ - **Tools**: every class method, with `inputSchema`, `outputSchema`, and `structuredContent` reflected from the types - **Instructions**: the reflected class/interface JSDoc, shipped through the handshake +- **OpenAPI**: `HttpLlm.controller()` documents serve the same way — every operation becomes a tool calling the real endpoint, and `info.version` becomes the server version - **Zero extra dependencies**: nothing at runtime beyond what `typia` already installs, plus the MCP SDK as a peer ## Setup @@ -27,7 +28,7 @@ npx typia setup ## Usage -`createMcpServer(controller, options?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. The options carry the handshake `version` (default `"1.0.0"`) and the behaviors below. +`createMcpServer(controller, options?)` — pass a `typia.llm.controller` (or an `HttpLlm.controller` over an OpenAPI document), connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. The handshake version is the OpenAPI `info.version` for HTTP controllers, `"1.0.0"` otherwise. ```typescript import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 665a2bec29..0bf4e0d538 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,17 +1,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ILlmController } from "@typia/interface"; +import { IHttpLlmController, ILlmController } from "@typia/interface"; import { McpControllerRegistrar } from "./internal/McpControllerRegistrar"; /** Options of {@link createMcpServer}. */ export interface IMcpServerOptions { - /** - * Server version for the MCP handshake. - * - * @default "1.0.0" - */ - version?: string | undefined; - /** * Whether to add a serialized JSON text block next to `structuredContent` in * every tool result. @@ -39,6 +32,10 @@ export interface IMcpServerOptions { * The controller's `name` becomes the server name, and the class JSDoc * (`application.description`) becomes the MCP handshake instructions. * + * An {@link IHttpLlmController} from `HttpLlm.controller()` works the same way: + * every OpenAPI operation becomes an MCP tool that calls the actual endpoint, + * and the document's `info.version` becomes the handshake version. + * * Every tool call is validated by typia. If the LLM provides invalid arguments, * it receives an {@link IValidation.IFailure} formatted by * {@link LlmJson.stringify} so it can correct them automatically — the exact @@ -68,18 +65,25 @@ export interface IMcpServerOptions { * ``` * * @template Class Executor class type of the controller - * @param controller Controller from `typia.llm.controller()` + * @param controller Controller from `typia.llm.controller()` or + * `HttpLlm.controller()` * @param options Optional behaviors of the server ({@link IMcpServerOptions}) * @returns McpServer ready to connect to a transport */ export function createMcpServer( - controller: ILlmController, + controller: ILlmController | IHttpLlmController, options?: IMcpServerOptions, ): McpServer { const instructions: string | undefined = - controller.application.description?.trim() || undefined; + controller.protocol === "http" + ? undefined + : controller.application.description?.trim() || undefined; + const version: string = + (controller.protocol === "http" + ? controller.application.version + : undefined) ?? "1.0.0"; const server: McpServer = new McpServer( - { name: controller.name, version: options?.version ?? "1.0.0" }, + { name: controller.name, version }, { capabilities: { tools: {} }, ...(instructions !== undefined ? { instructions } : {}), diff --git a/packages/mcp/src/internal/McpControllerRegistrar.ts b/packages/mcp/src/internal/McpControllerRegistrar.ts index d801d41293..e31a589fca 100644 --- a/packages/mcp/src/internal/McpControllerRegistrar.ts +++ b/packages/mcp/src/internal/McpControllerRegistrar.ts @@ -8,37 +8,33 @@ import { Tool, } from "@modelcontextprotocol/sdk/types.js"; import { + IHttpLlmController, + IHttpLlmFunction, + IHttpResponse, ILlmController, ILlmFunction, ILlmSchema, IValidation, } from "@typia/interface"; -import { LlmJson } from "@typia/utils"; +import { HttpLlm, LlmJson } from "@typia/utils"; export namespace McpControllerRegistrar { export const register = ( server: Server, - controller: ILlmController, + controller: ILlmController | IHttpLlmController, textFallback: boolean, ): void => { // Build tool registry from the controller's functions const registry: Map = new Map(); - const execute: Record = controller.execute; for (const func of controller.application.functions) { if (registry.has(func.name)) { throw new Error( `Duplicate function name "${func.name}" in controller "${controller.name}"`, ); } - const method: unknown = execute[func.name]; - if (typeof method !== "function") { - throw new Error( - `Method "${func.name}" not found on controller "${controller.name}"`, - ); - } registry.set(func.name, { function: func, - execute: async (args: unknown) => method.call(execute, args), + execute: composeExecute(controller, func), }); } @@ -73,6 +69,40 @@ export namespace McpControllerRegistrar { }); }; + const composeExecute = ( + controller: ILlmController | IHttpLlmController, + func: ILlmFunction, + ): ((args: unknown) => Promise) => { + if (controller.protocol === "http") { + const httpFunction: IHttpLlmFunction = func as IHttpLlmFunction; + return async (args: unknown) => { + if (controller.execute !== undefined) { + const response: IHttpResponse = await controller.execute({ + connection: controller.connection, + application: controller.application, + function: httpFunction, + arguments: args as object, + }); + return response.body; + } + return HttpLlm.execute({ + application: controller.application, + function: httpFunction, + connection: controller.connection, + input: args as object, + }); + }; + } + const execute: Record = controller.execute; + const method: unknown = execute[func.name]; + if (typeof method !== "function") { + throw new Error( + `Method "${func.name}" not found on controller "${controller.name}"`, + ); + } + return async (args: unknown) => method.call(execute, args); + }; + const handleToolCall = async ( entry: IToolEntry, args: unknown, diff --git a/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts b/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts index e61d6e73ff..ff97cde98c 100644 --- a/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts +++ b/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts @@ -98,10 +98,13 @@ export namespace HttpLlmApplicationComposer { }) .filter((v): v is IHttpLlmFunction => v !== null); + const version: string | undefined = + props.migrate.document().info?.version || undefined; const app: IHttpLlmApplication = { config, functions, errors, + ...(version !== undefined ? { version } : {}), }; shorten(app, props.config?.maxLength ?? 64); return app; diff --git a/tests/test-mcp/src/features/test_mcp_class_controller_coercion.ts b/tests/test-mcp/src/features/test_mcp_class_controller_coercion.ts index 394b43f04a..8cea3e06a4 100644 --- a/tests/test-mcp/src/features/test_mcp_class_controller_coercion.ts +++ b/tests/test-mcp/src/features/test_mcp_class_controller_coercion.ts @@ -47,11 +47,9 @@ export const test_mcp_class_controller_coercion = async (): Promise => { "coerced call is not an error", () => result.isError !== true, ); - TestValidator.predicate("coerced call returns the sum", () => - result.content.some( - (x) => - x.type === "text" && - (JSON.parse(x.text) as { value: number }).value === 7, - ), + TestValidator.equals( + "coerced call returns the sum", + result.structuredContent, + { value: 7 }, ); }; diff --git a/tests/test-mcp/src/features/test_mcp_class_controller_execute.ts b/tests/test-mcp/src/features/test_mcp_class_controller_execute.ts index 5c4eb1c41b..68014108c1 100644 --- a/tests/test-mcp/src/features/test_mcp_class_controller_execute.ts +++ b/tests/test-mcp/src/features/test_mcp_class_controller_execute.ts @@ -12,9 +12,9 @@ import { Calculator } from "../structures/Calculator"; * Verifies each class method executes as a tool and returns its result. * * Locks the happy-path dispatch of the tools/call handler: the method named in - * the request runs on the controller instance and its object return is - * serialized back as the tool result. A regression would misroute the call or - * drop the computed value. + * the request runs on the controller instance and its object return ships back + * as `structuredContent`. A regression would misroute the call or drop the + * computed value. * * 1. Serve a `Calculator` controller and grab its tools/call handler. * 2. Call add, subtract, multiply, and divide with concrete operands. @@ -39,7 +39,7 @@ export const test_mcp_class_controller_execute = async (): Promise => { ); TestValidator.equals( "add(10, 5) should return 15", - JSON.parse((addResult.content[0] as { text: string }).text), + addResult.structuredContent, { value: 15 }, ); @@ -52,7 +52,7 @@ export const test_mcp_class_controller_execute = async (): Promise => { ); TestValidator.equals( "subtract(10, 3) should return 7", - JSON.parse((subtractResult.content[0] as { text: string }).text), + subtractResult.structuredContent, { value: 7 }, ); @@ -65,7 +65,7 @@ export const test_mcp_class_controller_execute = async (): Promise => { ); TestValidator.equals( "multiply(4, 7) should return 28", - JSON.parse((multiplyResult.content[0] as { text: string }).text), + multiplyResult.structuredContent, { value: 28 }, ); @@ -78,7 +78,7 @@ export const test_mcp_class_controller_execute = async (): Promise => { ); TestValidator.equals( "divide(20, 4) should return 5", - JSON.parse((divideResult.content[0] as { text: string }).text), + divideResult.structuredContent, { value: 5 }, ); }; diff --git a/tests/test-mcp/src/features/test_mcp_create_server_lazy_controller.ts b/tests/test-mcp/src/features/test_mcp_create_server_lazy_controller.ts index 7fc2b804d1..7bb4a1a26b 100644 --- a/tests/test-mcp/src/features/test_mcp_create_server_lazy_controller.ts +++ b/tests/test-mcp/src/features/test_mcp_create_server_lazy_controller.ts @@ -34,7 +34,6 @@ export const test_mcp_create_server_lazy_controller = return { value: 42 }; }), ), - "0.16.8", ); const raw: Server = server.server; @@ -61,7 +60,7 @@ export const test_mcp_create_server_lazy_controller = TestValidator.equals("first call builds the state once", built, 1); TestValidator.equals( "call returns the reflected result", - JSON.parse((result.content[0] as { text: string }).text), + result.structuredContent, { answer: "depth=42" }, ); }; diff --git a/tests/test-mcp/src/features/test_mcp_create_server_version.ts b/tests/test-mcp/src/features/test_mcp_create_server_version.ts index e81720d775..5dc0b136b9 100644 --- a/tests/test-mcp/src/features/test_mcp_create_server_version.ts +++ b/tests/test-mcp/src/features/test_mcp_create_server_version.ts @@ -6,34 +6,23 @@ import typia from "typia"; import { Greeter } from "../structures/Greeter"; /** - * Verifies `options.version` reaches the MCP handshake `serverInfo`. + * Verifies a class controller announces the fixed `"1.0.0"` handshake version. * - * The server version lives in {@link IMcpServerOptions} next to the other - * optional behaviors, and it must land on the `Implementation` the SDK - * announces during initialization. A regression would pin every server to the - * `"1.0.0"` default no matter what the caller declares. + * `IMcpServerOptions` intentionally carries no version knob: an HTTP controller + * inherits the OpenAPI `info.version`, and a class controller has no version + * source, so the handshake pins the `"1.0.0"` default. A regression would + * announce `undefined` and break the SDK's `Implementation` contract. * - * 1. Create a server with `{ version: "2.5.0" }`. - * 2. Assert the underlying SDK server's `serverInfo.version` is `"2.5.0"`. - * 3. Create a server without options and assert the `"1.0.0"` default. + * 1. Create a server over a class controller. + * 2. Assert the underlying SDK server's `serverInfo.version` is `"1.0.0"`. */ export const test_mcp_create_server_version = async (): Promise => { - const explicit: McpServer = createMcpServer( + const server: McpServer = createMcpServer( typia.llm.controller("greeter", new Greeter()), - { version: "2.5.0" }, ); TestValidator.equals( - "explicit version should reach serverInfo", - ((explicit.server as any)._serverInfo as { version: string }).version, - "2.5.0", - ); - - const defaulted: McpServer = createMcpServer( - typia.llm.controller("greeter", new Greeter()), - ); - TestValidator.equals( - "omitted version should default to 1.0.0", - ((defaulted.server as any)._serverInfo as { version: string }).version, + "class controller should announce the 1.0.0 default", + ((server.server as any)._serverInfo as { version: string }).version, "1.0.0", ); }; diff --git a/tests/test-mcp/src/features/test_mcp_http_controller_execute.ts b/tests/test-mcp/src/features/test_mcp_http_controller_execute.ts new file mode 100644 index 0000000000..ad2651f09e --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_http_controller_execute.ts @@ -0,0 +1,85 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { TestValidator } from "@nestia/e2e"; +import { IHttpLlmController } from "@typia/interface"; +import { createMcpServer } from "@typia/mcp"; +import { HttpLlm } from "@typia/utils"; + +import { CalculatorApi } from "../structures/CalculatorApi"; + +/** + * Verifies an OpenAPI operation served as an MCP tool executes end-to-end. + * + * `createMcpServer` accepts an `IHttpLlmController`, registering every + * converted operation as a tool that runs through the controller's executor and + * ships the response body as `structuredContent`. A regression would confine + * `@typia/mcp` back to class controllers while the sibling adapters keep + * serving OpenAPI documents. + * + * 1. Build an HTTP controller whose executor answers in-process (no network). + * 2. Assert `tools/list` exposes the operation with its response `outputSchema`. + * 3. Call it and assert the response body arrives as `structuredContent` with no + * text duplicate. + */ +export const test_mcp_http_controller_execute = async (): Promise => { + const controller: IHttpLlmController = HttpLlm.controller({ + name: "calculator", + document: CalculatorApi.document(), + connection: { host: "http://localhost:0" }, + execute: async (props) => { + const { body } = props.arguments as { body: { x: number; y: number } }; + return { + status: 200, + headers: {}, + body: { value: body.x + body.y }, + }; + }, + }); + const server: McpServer = createMcpServer(controller); + + const rawServer: Server = server.server; + const requestHandlers: Map = (rawServer as any) + ._requestHandlers; + + const listHandler: Function = requestHandlers.get("tools/list")!; + const listed: { tools: Tool[] } = await listHandler( + { method: "tools/list", params: {} }, + { signal: new AbortController().signal }, + ); + TestValidator.equals( + "every converted operation should be listed", + listed.tools.map((tool: Tool) => tool.name), + controller.application.functions.map((func) => func.name), + ); + TestValidator.predicate( + "the operation should advertise its response outputSchema", + listed.tools[0]!.outputSchema !== undefined, + ); + + const callHandler: Function = requestHandlers.get("tools/call")!; + const result: CallToolResult = await callHandler( + { + method: "tools/call", + params: { + name: controller.application.functions[0]!.name, + arguments: { body: { x: 10, y: 5 } }, + }, + }, + { signal: new AbortController().signal }, + ); + TestValidator.predicate( + "http tool call should not be an error", + result.isError !== true, + ); + TestValidator.equals( + "response body should arrive as structuredContent", + result.structuredContent, + { value: 15 }, + ); + TestValidator.equals( + "content should stay empty without the opt-in text fallback", + result.content, + [], + ); +}; diff --git a/tests/test-mcp/src/features/test_mcp_http_controller_version.ts b/tests/test-mcp/src/features/test_mcp_http_controller_version.ts new file mode 100644 index 0000000000..54cbcdc510 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_http_controller_version.ts @@ -0,0 +1,35 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { TestValidator } from "@nestia/e2e"; +import { IHttpLlmController } from "@typia/interface"; +import { createMcpServer } from "@typia/mcp"; +import { HttpLlm } from "@typia/utils"; + +import { CalculatorApi } from "../structures/CalculatorApi"; + +/** + * Verifies an HTTP controller inherits the swagger `info.version` in the + * handshake. + * + * An OpenAPI document is the one controller source with a natural version: + * `HttpLlm.application()` preserves `info.version` onto + * `IHttpLlmApplication.version`, and `createMcpServer` announces it as the MCP + * `serverInfo.version`. A regression would pin every HTTP server to the + * `"1.0.0"` class-controller default. + * + * 1. Build an HTTP controller from a document whose `info.version` is known. + * 2. Create a server over it. + * 3. Assert the SDK server's `serverInfo.version` matches the document. + */ +export const test_mcp_http_controller_version = async (): Promise => { + const controller: IHttpLlmController = HttpLlm.controller({ + name: "calculator", + document: CalculatorApi.document(), + connection: { host: "http://localhost:0" }, + }); + const server: McpServer = createMcpServer(controller); + TestValidator.equals( + "handshake version should mirror the document info.version", + ((server.server as any)._serverInfo as { version: string }).version, + CalculatorApi.VERSION, + ); +}; diff --git a/tests/test-mcp/src/features/test_mcp_tool_omitted_arguments.ts b/tests/test-mcp/src/features/test_mcp_tool_omitted_arguments.ts index c5ba6132bd..1b88bb2596 100644 --- a/tests/test-mcp/src/features/test_mcp_tool_omitted_arguments.ts +++ b/tests/test-mcp/src/features/test_mcp_tool_omitted_arguments.ts @@ -42,7 +42,7 @@ export const test_mcp_tool_omitted_arguments = async (): Promise => { ); TestValidator.equals( "hello() should return the greeting", - JSON.parse((result.content[0] as { text: string }).text), + result.structuredContent, { message: "Hello, world!" }, ); }; diff --git a/tests/test-mcp/src/structures/CalculatorApi.ts b/tests/test-mcp/src/structures/CalculatorApi.ts new file mode 100644 index 0000000000..289c5c70e9 --- /dev/null +++ b/tests/test-mcp/src/structures/CalculatorApi.ts @@ -0,0 +1,58 @@ +import { OpenApiV3_1 } from "@typia/interface"; + +/** + * Minimal OpenAPI v3.1 document serving one calculator operation. + * + * Fixture for the HTTP-controller tests: `POST /calculator/add` takes a `{ x, y + * }` body and answers `{ value }`, so both the reflected input schema and the + * structured output path can be asserted without a network. + */ +export namespace CalculatorApi { + /** API version declared in the document's `info`. */ + export const VERSION = "3.2.1"; + + /** Compose the OpenAPI document. */ + export const document = (): OpenApiV3_1.IDocument => ({ + openapi: "3.1.0", + info: { title: "Calculator API", version: VERSION }, + paths: { + "/calculator/add": { + post: { + description: "Add two numbers.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + x: { type: "number" }, + y: { type: "number" }, + }, + required: ["x", "y"], + }, + }, + }, + }, + responses: { + "200": { + description: "Sum of the two numbers.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + value: { type: "number" }, + }, + required: ["value"], + }, + }, + }, + }, + }, + }, + }, + }, + components: {}, + }); +} diff --git a/tests/test-utils/src/features/llm/http/test_http_llm_application_version.ts b/tests/test-utils/src/features/llm/http/test_http_llm_application_version.ts new file mode 100644 index 0000000000..dc19142e19 --- /dev/null +++ b/tests/test-utils/src/features/llm/http/test_http_llm_application_version.ts @@ -0,0 +1,35 @@ +import { TestValidator } from "@nestia/e2e"; +import { IHttpLlmApplication, OpenApi } from "@typia/interface"; +import { HttpLlm, OpenApiConverter } from "@typia/utils"; +import fs from "fs"; + +import { TestGlobal } from "../../../TestGlobal"; + +/** + * Verifies `HttpLlm.application()` preserves the OpenAPI `info.version`. + * + * The document's `info.version` is the natural version source for an + * HTTP-derived application, and downstream adapters (e.g. `@typia/mcp`'s + * handshake `serverInfo.version`) read it from `IHttpLlmApplication.version`. A + * regression would silently discard it and every adapter would fall back to its + * own default. + * + * 1. Load the swagger fixture and upgrade it to the emended format. + * 2. Compose the application through `HttpLlm.application()`. + * 3. Assert `application.version` mirrors `document.info.version`. + */ +export const test_http_llm_application_version = async (): Promise => { + const document: OpenApi.IDocument = OpenApiConverter.upgradeDocument( + JSON.parse( + await fs.promises.readFile(`${TestGlobal.ROOT}/swagger.json`, "utf8"), + ), + ); + const application: IHttpLlmApplication = HttpLlm.application({ + document, + }); + TestValidator.equals( + "application.version should mirror the document info.version", + application.version, + document.info?.version, + ); +}; diff --git a/website/src/content/docs/utilization/mcp.mdx b/website/src/content/docs/utilization/mcp.mdx index 903f5eec58..f2c4be2888 100644 --- a/website/src/content/docs/utilization/mcp.mdx +++ b/website/src/content/docs/utilization/mcp.mdx @@ -11,16 +11,15 @@ import LocalSource from "../../../components/LocalSource"; ```typescript copy filename="signature" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ILlmController } from "@typia/interface"; +import { IHttpLlmController, ILlmController } from "@typia/interface"; import { createMcpServer } from "@typia/mcp"; export function createMcpServer( - controller: ILlmController, + controller: ILlmController | IHttpLlmController, options?: IMcpServerOptions, ): McpServer; export interface IMcpServerOptions { - version?: string; // default "1.0.0" textFallback?: boolean; // default false } ``` @@ -94,6 +93,27 @@ const server = createMcpServer( **Method type rules.** Every method's parameter type must be a keyworded object with static keys (no primitives, arrays, or unions). The return type must be a single object type or `void`. See [`typia.llm.application` restrictions](/docs/llm/application#restrictions) for the full list. +## HTTP controllers + +An OpenAPI document can be the controller instead of a class. `HttpLlm.controller()` from `@typia/utils` converts every operation into an LLM function, and `createMcpServer` serves each one as an MCP tool that calls the actual endpoint: + +```typescript filename="src/main.ts" showLineNumbers {5-12} +import { createMcpServer } from "@typia/mcp"; +import { HttpLlm } from "@typia/utils"; + +const server = createMcpServer( + HttpLlm.controller({ + name: "shopping", + document: await fetch( + "https://shopping-be.wrtn.io/editor/swagger.json", + ).then((r) => r.json()), + connection: { host: "https://shopping-be.wrtn.io" }, + }), +); +``` + +The handshake version is the document's `info.version` (a class controller announces `"1.0.0"`). Argument validation, structured output, and error feedback behave exactly as with class controllers. + ## Server instructions MCP `instructions` tell the client's LLM what the server is for and how to drive it. With `@typia/mcp` you do not write them separately — **the JSDoc comment on the controller class (or interface) is the instructions.** [`typia.llm.application`](/docs/llm/application#application-description) reflects that doc comment onto [`ILlmApplication.description`](/docs/llm/application#application-description), and `createMcpServer` ships it verbatim as the handshake `instructions`. Write the usage contract once, as documentation on the type, and it reaches every connected client automatically.