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 { version?: string; 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()), { version: "2.3.4" }, ); await server.connect(new StdioServerTransport());

version identifies the deployed MCP server implementation in the initialize handshake. Supply the host applicationโ€™s release version when it has one. Without this option, an HTTP controller inherits OpenAPI info.version and a class controller keeps the backward-compatible "1.0.0" fallback.

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 defaults to the documentโ€™s info.version (a class controller defaults to "1.0.0"). An explicit options.version takes precedence when the deployed MCP server version differs from the served API version. 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.

Before returning a successful structured result, @typia/mcp validates the controller value against that reflected schema, including required fields, nested types, and additional properties. A mismatch returns isError: true with annotated validation paths and no structuredContent, so the client receives an in-band tool error instead of rejecting a malformed protocol response.

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 three distinct failure modes when a tool is called, and the server keeps the MCP conversation alive through all of them:

  • 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.
  • Output validation error โ€” the controller returned a value that doesnโ€™t match its reflected return type. The tool returns isError: true with annotated output paths instead of sending invalid structuredContent.
  • 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 all three 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