Skip to Content
๐Ÿ“– Guide DocumentsUtilization CasesMCP

MCP

@typia/mcp registers typia controllers as Model Context Protocolย  tools. Every method on your TypeScript class โ€” or every endpoint in an OpenAPI document โ€” becomes an MCP tool, with the same parse/coerce/validate/feedback machinery as typia.llm.application wired in automatically.

signature
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerMcpControllers } from "@typia/mcp"; export function registerMcpControllers(props: { server: McpServer | Server; controllers: Array<ILlmController | IHttpLlmController>; preserve?: boolean; }): void;
ArgumentMeaning
serverAn MCP server instance (McpServer or raw Server from the MCP SDK)
controllersAn array of controllers โ€” either typia.llm.controller<Class>() or HttpLlm.controller()
preservefalse (default): typia owns the serverโ€™s tool list. true: coexist with the MCP SDKโ€™s McpServer.registerTool() calls.

undefined

@typia/mcp
export function registerMcpControllers(props: { server: McpServer | Server; controllers: Array<ILlmController | IHttpLlmController>; preserve?: boolean | undefined; }): void;

Setup

Terminal
npm install @typia/mcp @modelcontextprotocol/sdk npm install typia npx typia setup

From a TypeScript class

src/main.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerMcpControllers } from "@typia/mcp"; import typia from "typia"; import { BbsArticleService } from "./BbsArticleService"; import { Calculator } from "./Calculator"; const server: McpServer = new McpServer({ name: "my-server", version: "1.0.0", }); registerMcpControllers({ server, controllers: [ typia.llm.controller<Calculator>("calculator", new Calculator()), typia.llm.controller<BbsArticleService>("bbs", new BbsArticleService()), ], });

Every method on Calculator and BbsArticleService becomes an MCP tool. JSDoc comments turn into tool descriptions, TypeScript types turn into JSON schemas. Tool names are {controllerName}_{methodName} (e.g. calculator_add, bbs_create).

Calculator.ts
export class Calculator { /** * Add two numbers. * * @param p The input containing two numbers to add * @returns The sum of a and b */ add(p: Calculator.IProps): Calculator.IResult { return { value: p.x + p.y }; } /** * Subtract two numbers. * * @param p The input containing two numbers to subtract * @returns The difference of a and b */ subtract(p: Calculator.IProps): Calculator.IResult { return { value: p.x - p.y }; } /** * Multiply two numbers. * * @param p The input containing two numbers to multiply * @returns The product of a and b */ multiply(p: Calculator.IProps): Calculator.IResult { return { value: p.x * p.y }; } /** * Divide two numbers. * * @param p The input containing two numbers to divide * @returns The quotient of a and b */ divide(p: Calculator.IProps): Calculator.IResult { if (p.y === 0) { throw new Error("Division by zero is not allowed"); } return { value: p.x / p.y }; } } export namespace Calculator { export interface IProps { /** First operand */ x: number; /** Second operand */ y: number; } /** Result of a calculation. */ export interface IResult { /** Calculated value */ value: number; } }

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 an object or void. See typia.llm.application restrictions for the full list.

From an OpenAPI document

When the API you want to expose is described by Swagger/OpenAPI, use HttpLlm.controller โ€” it produces controllers that registerMcpControllers accepts the same way:

src/main.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerMcpControllers } from "@typia/mcp"; import { HttpLlm } from "@typia/utils"; const server: McpServer = new McpServer({ name: "my-server", version: "1.0.0", }); registerMcpControllers({ server, controllers: [ HttpLlm.controller({ name: "shopping", document: await fetch( "https://shopping-be.wrtn.ai/editor/swagger.json", ).then((r) => r.json()), connection: { host: "https://shopping-be.wrtn.ai", headers: { Authorization: "Bearer ********" }, }, }), ], });

Every operation in the Swagger document becomes an MCP tool, with the same harness wrapping each one.

The function calling harness

Every tool registered through registerMcpControllers carries built-in lenient JSON parsing, type coercion, and validation feedback โ€” same as the underlying typia.llm.application. When validation fails, the tool returns the input annotated with // โŒ markers, which the LLM reads and self-corrects from:

{ "name": "John", "age": "twenty", // โŒ [{"path":"$input.age","expected":"number"}] "email": "not-an-email", // โŒ [{"path":"$input.email","expected":"string & Format<\"email\">"}] "hobbies": "reading" // โŒ [{"path":"$input.hobbies","expected":"Array<string>"}] }

For the mechanics of parse / coerce / validate / stringify, see LlmJson. For the same harness pattern in typia.llm.application, see Function calling harness.

Preserve mode

By default registerMcpControllers owns the serverโ€™s tool list. If youโ€™ve already registered tools the standard way with McpServer.registerTool(...) and want typiaโ€™s tools to coexist, pass preserve: true.

test_mcp_class_controller_preserve.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TestValidator } from "@nestia/e2e"; import { ILlmController } from "@typia/interface"; import { registerMcpControllers } from "@typia/mcp"; import typia from "typia"; import { z } from "zod"; import { Calculator } from "../structures/Calculator"; export const test_mcp_class_controller_preserve = async (): Promise<void> => { // 1. Create class-based controller using typia.llm.controller const controller: ILlmController<Calculator> = typia.llm.controller<Calculator>("calculator", new Calculator()); // 2. Create McpServer with tools capability const mcpServer: McpServer = new McpServer( { name: "test-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); // 3. Register existing tool via McpServer.registerTool() with zod schema const inputSchema: z.ZodObject<{ message: z.ZodString }> = z.object({ message: z.string().describe("Message to echo"), }); (mcpServer.registerTool as Function)( "echo", { description: "Echo a message", inputSchema, }, async (args: z.infer<typeof inputSchema>) => ({ content: [ { type: "text", text: args.message, }, ], }), ); // 4. Register with preserve: true registerMcpControllers({ server: mcpServer, controllers: [controller], preserve: true, }); // 5. Verify private API IS used const registeredTools: Record<string, unknown> = (mcpServer as any)._registeredTools ?? {}; const handlersInitialized: boolean = (mcpServer as any) ._toolHandlersInitialized; TestValidator.equals( "existing echo tool should be present", Object.keys(registeredTools).includes("echo"), true, ); TestValidator.equals( "_toolHandlersInitialized should be true", handlersInitialized, true, ); // 6. Call tools/list to verify all tools (echo + calculator) const rawServer: Server = mcpServer.server; const requestHandlers: Map<string, Function> = (rawServer as any) ._requestHandlers; const listHandler: Function = requestHandlers.get("tools/list")!; const result: { tools: { name: string }[] } = await listHandler( { method: "tools/list", params: {}, }, { signal: new AbortController().signal, }, ); const toolNames: string[] = result.tools.map((t) => t.name).sort(); // Should have 5 tools: echo + add, subtract, multiply, divide TestValidator.equals("should have 5 tools total", result.tools.length, 5); TestValidator.predicate( "should include echo tool", toolNames.includes("echo"), ); TestValidator.predicate("should include add tool", toolNames.includes("add")); TestValidator.predicate( "should include subtract tool", toolNames.includes("subtract"), ); TestValidator.predicate( "should include multiply tool", toolNames.includes("multiply"), ); TestValidator.predicate( "should include divide tool", toolNames.includes("divide"), ); };

In the test above, an "echo" tool registered through McpServer.registerTool lives alongside four calculator tools registered through registerMcpControllers โ€” five tools total. If two registrations would produce the same tool name, registration fails at startup so you can fix it before clients ever see the conflict.

Runtime errors

There are two distinct failure modes when a tool is called:

  • Validation error โ€” the LLM passed arguments that donโ€™t match the schema. The tool returns isError: true with the annotated input (same // โŒ markers as above) so the model can fix it.
  • Runtime error โ€” the tool itself threw (e.g. divide-by-zero, network error, business-rule violation). The tool returns isError: true with the error name and message.

In both cases the MCP conversation stays alive โ€” the model sees the failure and can either retry with different inputs or inform the user.

test_mcp_class_controller_error_handling.ts
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 { ILlmController } from "@typia/interface"; import { registerMcpControllers } from "@typia/mcp"; import typia from "typia"; import { Calculator } from "../structures/Calculator"; /** * Verifies MCP tool handler catches runtime errors and returns `isError: true`. * * Locks the error-catching branch of `McpControllerRegistrar.handleToolCall`. * When a tool's `execute` function throws (e.g. division by zero), the handler * must catch the error and return a well-formed `CallToolResult` with * `isError: true` and the error message as text content, rather than letting * the exception propagate and crash the MCP server. * * 1. Register a `Calculator` controller with the MCP server. * 2. Invoke the `divide` tool with `y: 0` to trigger a division-by-zero error. * 3. Assert the result has `isError: true`. * 4. Assert the text content contains the "Division by zero" error message. */ export const test_mcp_class_controller_error_handling = async (): Promise<void> => { // 1. Create class-based controller using typia.llm.controller const controller: ILlmController<Calculator> = typia.llm.controller<Calculator>("calculator", new Calculator()); // 2. Create McpServer with tools capability const mcpServer: McpServer = new McpServer( { name: "test-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); // 3. Register controller registerMcpControllers({ server: mcpServer, controllers: [controller], preserve: false, }); // 4. Get tools/call handler const rawServer: Server = mcpServer.server; const requestHandlers: Map<string, Function> = (rawServer as any) ._requestHandlers; const callHandler: Function = requestHandlers.get("tools/call")!; // 5. Test divide by zero (throws an error) const result: CallToolResult = await callHandler( { method: "tools/call", params: { name: "divide", arguments: { x: 10, y: 0, }, }, }, { signal: new AbortController().signal }, ); // 6. Verify the result is an error TestValidator.predicate( "result should have isError: true", () => result.isError === true, ); // 7. Verify the error message contains the expected text TestValidator.predicate( "error should contain division by zero message", () => result.content.some( (c) => c.type === "text" && (c as { type: "text"; text: string }).text.includes( "Division by zero", ), ), ); };

Where to go next

Last updated on