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.
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;| Argument | Meaning |
|---|---|
server | An MCP server instance (McpServer or raw Server from the MCP SDK) |
controllers | An array of controllers โ either typia.llm.controller<Class>() or HttpLlm.controller() |
preserve | false (default): typia owns the serverโs tool list. true: coexist with the MCP SDKโs McpServer.registerTool() calls. |
undefined
export function registerMcpControllers(props: {
server: McpServer | Server;
controllers: Array<ILlmController | IHttpLlmController>;
preserve?: boolean | undefined;
}): void;Setup
npm install @typia/mcp @modelcontextprotocol/sdk
npm install typia
npx typia setupFrom a TypeScript class
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).
undefined
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:
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.
Preserve test
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: truewith 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: truewith 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.
Error handling test
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
- Same idea, different framework โ Vercel AI SDK ยท LangChain
- Source TypeScript class for the tools โ
typia.llm.application - Source OpenAPI document for the tools โ
HttpLlm - Harness internals (
parse,coerce,validate,stringify) โLlmJson - Building a full chatbot โ Agentica