@McpRoute
export function McpRoute(name: string): MethodDecorator;
export namespace McpRoute {
export function Params(): ParameterDecorator;
}
export class McpAdaptor {
public static upgrade(
app: INestApplication,
options?: McpAdaptor.IOptions,
): Promise<void>;
}@McpRoute() exposes a NestJS controller method as an MCP (Model Context
Protocol) tool.
The common flow is: an LLM client connects to your NestJS application through
MCP Streamable HTTP, lists decorated tools, and calls them by tool name.
@nestia/core analyzes the params and return types at compile time, then emits
MCP-compatible JSON schemas and request validators.
McpAdaptor supports stateless Streamable HTTP tool endpoints. It does not
keep Mcp-Session-Id state, and it does not implement MCP resources, prompts,
sampling, or elicitation.
Install @modelcontextprotocol/sdk in the server application before calling
McpAdaptor.upgrade(). @nestia/core loads it only when MCP is enabled.
Basic Usage
undefined
import core from "@nestia/core";
import { Controller } from "@nestjs/common";
export interface ICalcInput {
a: number;
b: number;
}
export interface ICalcResult {
result: number;
}
@Controller()
export class CalculatorController {
/** Return the sum of two numbers. */
@core.McpRoute("add")
public async add(
@core.McpRoute.Params() params: ICalcInput,
): Promise<ICalcResult> {
return { result: params.a + params.b };
}
/** Accept a notification without returning content. */
@core.McpRoute("notify")
public async notify(
@core.McpRoute.Params() params: { message: string },
): Promise<void> {
void params;
}
}The name passed to @McpRoute() becomes the MCP tool name, so it must be
unique in the application. Descriptions are read from the method JSDoc comment,
and @title can provide a UI title.
McpAdaptor.upgrade() must run before app.listen(). Without the adaptor,
decorated methods are only metadata and no MCP HTTP endpoint is mounted.
Keep MCP tools on their own controller methods. A method decorated with
@McpRoute() must not also carry an HTTP route decorator such as
@TypedRoute.Get() or a @WebSocketRoute() decorator; generate a separate
method when the same business operation needs multiple protocol surfaces.
Installation
npm install @modelcontextprotocol/sdk@nestia/core does not force every user to install the MCP SDK. The package is
loaded lazily when McpAdaptor.upgrade() is called.
When generating a distributable SDK package with nestia.config.ts
distribute, @nestia/sdk adds @modelcontextprotocol/sdk to the generated
package dependencies only if MCP routes exist.
Parameters
Every MCP tool must have exactly one parameter, and it must be decorated with
@McpRoute.Params().
The parameter type must be a single object type without dynamic keys. Index
signatures and Record<string, T> are rejected at compile time because MCP tool
arguments must have statically known JSON schema properties.
The tool receives the successful assert return value or validate resultโs data; is keeps the original arguments. Clone validators provide a separate object, while prune validators mutate the input in place. Validation failures retain the same InvalidParams response.
@core.McpRoute("get_weather")
public async getWeather(
@core.McpRoute.Params() params: {
location: string;
unit: "celsius" | "fahrenheit";
},
): Promise<{ temperature: number }> {
return { temperature: 24 };
}@core.McpRoute("bad")
public async bad(
@core.McpRoute.Params() params: Record<string, number>,
): Promise<{ ok: boolean }> {
return { ok: true };
}Return Type
MCP tools may return either:
void- a single object type without dynamic keys
void | object unions are rejected because the generated client needs one
stable output contract. Dynamic-key object returns are also rejected for the same
schema reason as params.
For object returns, Nestia serializes the result as JSON text content and the
generated SDK wrapper parses it back into the declared output type. For void
returns, Nestia returns an empty MCP content array and the generated SDK wrapper
returns Promise<void>.
Guards, Interceptors, Pipes, and Filters
A tool call passes the enhancers NestJS would apply to an HTTP route on the same
method: guards, interceptors, pipes, and exception filters, whether bound to the
method, its controller, or globally (app.useGlobalGuards(), APP_GUARD, and
the rest). The execution context is the MCP HTTP request, so a guard reading
context.switchToHttp().getRequest() works unchanged, and the client sends the
header it reads:
@UseGuards(JwtAuthGuard)
@Controller("secret")
export class SecretController {
@core.McpRoute("read_secret")
public async read(
@core.McpRoute.Params() params: { id: string },
): Promise<ISecret> { ... }
}new StreamableHTTPClientTransport(new URL("http://localhost:3000/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${token}` } },
});- Guards run first. A denial is a tool error:
isError: truewith the message,Forbidden resourceby default. - Arguments are validated after the guards, as
@TypedBody()is, so an unauthorized caller learns nothing of the schema. Then the pipes receive them as the request body. - Interceptors wrap the call and may transform its result.
- Exception filters see what the tool throws. A filter throwing an
HttpExceptionmakes that the tool error. A filter answering the HTTP request itself sends that response instead of the MCP result.
A request-scoped controller or enhancer is built for each tool call, with the
MCP HTTP request injected as REQUEST, as NestJS builds it for an HTTP route.
A thrown HttpException that no filter handles is a tool error with its
message. An invalid argument is JSON-RPC -32602, and any other throw is
-32603.
McpAdaptor.upgrade(app, { path }) mounts the endpoint at path exactly as
given. The applicationโs global prefix does not apply, so with
app.setGlobalPrefix("api") the endpoint is still /mcp, unless you pass
{ path: "/api/mcp" }.
Generated SDK
Related Document: Software Development Kit
When you run npx nestia sdk, MCP routes are emitted under
api.functional.mcp.
import type { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
import type { CallToolResult as McpCallToolResult } from "@modelcontextprotocol/sdk/types.js";
export async function add(
client: McpClient,
args: add.Input,
): Promise<add.Output> {
const raw = await client.callTool({
name: add.METADATA.tool,
arguments: args as any as Record<string, any>,
});
if (Object.prototype.hasOwnProperty.call(raw, "toolResult"))
throw new Error(
`MCP tool "${add.METADATA.tool}" returned a legacy (pre-2024-11-05) compatibility result`,
);
const result: McpCallToolResult = raw as McpCallToolResult;
if (result.isError === true)
throw new Error(
`MCP tool "${add.METADATA.tool}" returned isError: ${result.content
.filter((entry: any) => entry.type === "text")
.map((entry: any) => entry.text)
.join("\n")}`,
);
const first = result.content.find((entry: any) => entry.type === "text");
if (first === undefined)
throw new Error(`MCP tool "${add.METADATA.tool}" returned no text content`);
if (first.type === "text") return JSON.parse(first.text) as add.Output;
throw new Error(`MCP tool "${add.METADATA.tool}" returned no text content`);
}A tool error, such as a guardโs denial or an HttpException the tool throws, rejects with the toolโs text content in the message: MCP tool "add" returned isError: Forbidden resource.
The generated function receives an already connected MCP Client. Nestia does
not create the MCP transport for you, because transport lifecycle, auth headers,
and client identity belong to the caller.
MCP SDK imports are aliased (McpClient, McpCallToolResult) so that your DTO
names may still be Client or CallToolResult.