Skip to Content
📖 Guide DocumentsUtilization CasesMCP

MCP

@typia/mcp builds a Model Context Protocol  server over a single typia controller. Every tool’s input schema, output schema, argument validation, and the handshake instructions all derive from TypeScript types and JSDoc — there is no hand-written JSON schema or Zod shape anywhere:

signature
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { IHttpLlmController, ILlmController } from "@typia/interface"; import { createMcpServer } from "@typia/mcp"; export function createMcpServer<Class extends object = any>( controller: ILlmController<Class> | IHttpLlmController, options?: IMcpServerOptions, ): McpServer; export interface IMcpServerOptions { textFallback?: boolean; // default false }

The class you would hand to typia.llm.application<Class>() 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. You pass the class through typia.llm.controller<Class>(name, instance) — 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:

src/main.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createMcpServer } from "@typia/mcp"; import typia from "typia"; import { BbsArticleService } from "./BbsArticleService"; const server = createMcpServer( typia.llm.controller<BbsArticleService>("bbs", new BbsArticleService()), ); await server.connect(new StdioServerTransport());

Setup

Terminal
npm install @typia/mcp @modelcontextprotocol/sdk npm install typia npm install -D ttsc typescript

@typia/mcp adds zero runtime dependencies beyond what typia itself already installs, plus the MCP SDK as a peer — the entire server surface is declared through createMcpServer.

Tools

Every method of the controller class becomes an MCP tool named after the method: JSDoc comments turn into tool descriptions, parameter types turn into inputSchema, and return types turn into outputSchema. To serve multiple services from one server, compose them into one facade class.

src/main.ts
import { createMcpServer } from "@typia/mcp"; import typia from "typia"; const server = createMcpServer( typia.llm.controller<Calculator>("calculator", new Calculator()), );

undefined

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 a single object type or void. See typia.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:

src/main.ts
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 reflects that doc comment onto ILlmApplication.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.

So the comment above class BbsArticleService is not a comment for human readers — it is the agent’s operating manual. Give it the sections an agent needs: what the server manages, which tool to reach for, and the rules it must not break.

BbsArticleService.ts
/** * Bulletin board article service. * * Manage the articles of a bulletin board — list them, write new ones, edit an * existing one, or remove it. Every article is identified by its UUID `id`. * * ## Which tool to call * - Browsing, or "show me the articles" → `index` * - Writing or posting a new article → `create` * - Editing an existing article → `update` (only the supplied fields change) * - Deleting an article → `erase` * * ## Rules * - Never call `update` or `erase` without a concrete `id`; confirm the target * article with the user first. * - Leave `thumbnail` as `null` unless the user supplies an image URL. */ export class BbsArticleService { public index(): IBbsArticle.IPage; public create(props: { input: IBbsArticle.ICreate }): IBbsArticle; public update(props: { id: string; input: IBbsArticle.IUpdate }): void; public erase(props: { id: string }): void; }

Because the description covers the whole toolset, agent frameworks surface it as a system-level instruction rather than a per-tool hint. The controller is constructed by the caller, so the executor can defer expensive work until the first tool call while the handshake and instructions still answer immediately — a large project never stalls the connection just to describe itself.

For a production example of instructions written entirely as interface JSDoc, see @ttsc/graph’s ITtscGraphApplication: the interface-level comment carries the server’s entire usage contract — what the tool returns, when to stop calling it, how to read its result — and that block becomes the MCP handshake instructions with no separate configuration.

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 — MCP’s structured tool output, for free, by construction. A Calculator.add(props): { value: number } tool therefore lists

{ "type": "object", "properties": { "value": { "type": "number" } }, "required": ["value"] }

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:

src/main.ts
const server = createMcpServer( typia.llm.controller<BbsArticleService>("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

Every tool call runs typia’s lenient JSON parsing, type coercion, and validation — the same harness as typia.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:

{ "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.

Runtime errors

There are two distinct failure modes when a tool is called, and the server keeps the MCP conversation alive through both:

  • Validation error — the LLM passed arguments that don’t match the schema. The tool returns isError: true with the annotated input (the same // ❌ markers as above) so the model can fix it.
  • Runtime error — the tool itself threw (e.g. divide-by-zero, network failure, business-rule violation). The tool returns isError: true with the error name and message, instead of letting the exception propagate and crash the server.

In both cases the model sees the failure and can either retry with different inputs or report back to the user, so a single bad call never tears down the session.

Where to go next

Last updated on