diff --git a/packages/interface/src/http/IHttpLlmApplication.ts b/packages/interface/src/http/IHttpLlmApplication.ts index 4b08057dddd..251ee74eee6 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 68c2a6b1677..f4799b9e84d 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, 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, 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"; @@ -48,6 +49,14 @@ const server = createMcpServer( await server.connect(new StdioServerTransport()); ``` +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: 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 0b23c0fcb18..0bf4e0d5382 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,8 +1,28 @@ 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 { + /** + * Whether to add a serialized JSON text block next to `structuredContent` in + * every tool result. + * + * 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; 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. + * + * @default false + */ + textFallback?: boolean | undefined; +} + /** * Create an MCP server over a single typia controller. * @@ -12,6 +32,10 @@ import { McpControllerRegistrar } from "./internal/McpControllerRegistrar"; * 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 @@ -41,16 +65,23 @@ 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 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, - version: string = "1.0.0", + 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 }, { @@ -58,6 +89,10 @@ export function createMcpServer( ...(instructions !== undefined ? { instructions } : {}), }, ); - McpControllerRegistrar.register(server.server, controller); + McpControllerRegistrar.register( + server.server, + controller, + options?.textFallback === true, + ); return server; } diff --git a/packages/mcp/src/internal/McpControllerRegistrar.ts b/packages/mcp/src/internal/McpControllerRegistrar.ts index d7389c89535..e31a589fca1 100644 --- a/packages/mcp/src/internal/McpControllerRegistrar.ts +++ b/packages/mcp/src/internal/McpControllerRegistrar.ts @@ -8,36 +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), }); } @@ -68,13 +65,48 @@ export namespace McpControllerRegistrar { `Unknown tool: ${request.params.name}`, ); } - return handleToolCall(entry, request.params.arguments); + return handleToolCall(entry, request.params.arguments, textFallback); }); }; + 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, + 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 +129,24 @@ 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, 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" && result !== null && - !Array.isArray(result) + !Array.isArray(result); + return { + content: + structured && !textFallback + ? [] + : [{ type: "text" as const, text: JSON.stringify(result) }], + ...(structured ? { structuredContent: result as Record } : {}), }; diff --git a/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts b/packages/utils/src/http/internal/HttpLlmApplicationComposer.ts index e61d6e73ffe..ff97cde98c9 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 394b43f04a9..8cea3e06a42 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 5c4eb1c41b7..68014108c13 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 7fc2b804d1b..7bb4a1a26bf 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 new file mode 100644 index 00000000000..5dc0b136b92 --- /dev/null +++ b/tests/test-mcp/src/features/test_mcp_create_server_version.ts @@ -0,0 +1,28 @@ +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 a class controller announces the fixed `"1.0.0"` handshake version. + * + * `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 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 server: McpServer = createMcpServer( + typia.llm.controller("greeter", new Greeter()), + ); + TestValidator.equals( + "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 00000000000..ad2651f09ec --- /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 00000000000..54cbcdc5107 --- /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_markdown_structured_content.ts b/tests/test-mcp/src/features/test_mcp_tool_markdown_structured_content.ts index 413a9557a78..b7d5bf4752f 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_omitted_arguments.ts b/tests/test-mcp/src/features/test_mcp_tool_omitted_arguments.ts index c5ba6132bda..1b88bb25964 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/features/test_mcp_tool_output_schema.ts b/tests/test-mcp/src/features/test_mcp_tool_output_schema.ts index 3a3f183843a..58b8819f7ec 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_enabled.ts b/tests/test-mcp/src/features/test_mcp_tool_text_fallback_enabled.ts new file mode 100644 index 00000000000..1bb90dc791e --- /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/tests/test-mcp/src/structures/CalculatorApi.ts b/tests/test-mcp/src/structures/CalculatorApi.ts new file mode 100644 index 00000000000..289c5c70e9a --- /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 00000000000..dc19142e195 --- /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 50f2f14e440..f2c4be2888d 100644 --- a/website/src/content/docs/utilization/mcp.mdx +++ b/website/src/content/docs/utilization/mcp.mdx @@ -11,13 +11,17 @@ 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, - version?: string, // default "1.0.0" + controller: ILlmController | IHttpLlmController, + options?: IMcpServerOptions, ): McpServer; + +export interface IMcpServerOptions { + textFallback?: boolean; // default false +} ``` 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: @@ -89,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. @@ -127,13 +152,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 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: true }, +); +``` + +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