@nestia/core
Version:
Super-fast validation decorators of NestJS
285 lines (266 loc) • 9.53 kB
text/typescript
import {
BadRequestException,
HttpException,
INestApplication,
} from "@nestjs/common";
import { NestContainer } from "@nestjs/core";
import { IMcpRouteReflect } from "../decorators/internal/IMcpRouteReflect";
/**
* MCP (Model Context Protocol) adaptor.
*
* `McpAdaptor` exposes every method decorated with {@link McpRoute} as an MCP
* tool, reachable by LLM clients through a stateless Streamable HTTP endpoint.
*
* At bootstrap the adaptor walks the {@link NestContainer}, collects every
* controller method carrying `"nestia/McpRoute"` metadata, and caches a tool
* registry. A fresh MCP server and transport pair is spun up per incoming HTTP
* request, following MCP stateless Streamable HTTP mode. This adaptor
* intentionally does not manage `Mcp-Session-Id` state.
*
* Typia-generated JSON Schemas flow through unchanged; the Zod-based high-level
* registration API of `McpServer` is bypassed by accessing the low-level
* `.server` handler.
*
* Error mapping follows the MCP specification:
*
* - Unknown tool name: JSON-RPC `-32601`.
* - Typia validation failure: JSON-RPC `-32602` with structured diagnostics.
* - Handler throws {@link HttpException}: success response with `isError: true`,
* so the LLM can read the message and recover.
* - Any other throw: JSON-RPC `-32603`.
*
* @author wildduck - https://github.com/wildduck2
* @example
* ```typescript
* import core from "@nestia/core";
* import { NestFactory } from "@nestjs/core";
*
* const app = await NestFactory.create(AppModule);
* await core.McpAdaptor.upgrade(app, { path: "/mcp" });
* await app.listen(3000);
* ```;
*/
export class McpAdaptor {
/**
* Upgrade a running Nest application with a stateless MCP endpoint.
*
* Scans the application container for methods decorated with {@link McpRoute},
* then registers a catch-all HTTP route at the configured path. Each incoming
* request builds a fresh MCP server + transport on demand, wires the
* registered tools into it, and delegates handling.
*
* Must be called after `NestFactory.create(...)` but before `app.listen(...)`
* if you want the MCP endpoint to be reachable alongside your regular HTTP
* routes.
*
* @param app Running Nest application instance.
* @param options Transport and identity overrides.
*/
public static async upgrade(
app: INestApplication,
options: McpAdaptor.IOptions = {},
): Promise<void> {
if ("sessioned" in (options as Record<string, unknown>))
throw new Error(
"McpAdaptor.upgrade() supports stateless Streamable HTTP only; sessioned mode is not implemented.",
);
const tools: McpAdaptor.ITool[] = [];
const container = (app as any).container as NestContainer;
for (const module of container.getModules().values()) {
for (const wrapper of module.controllers.values()) {
const instance = wrapper.instance;
if (!instance) continue;
const visited: Set<string> = new Set();
for (
let proto: any = Object.getPrototypeOf(instance);
proto !== null && proto !== Object.prototype;
proto = Object.getPrototypeOf(proto)
) {
for (const key of Object.getOwnPropertyNames(proto)) {
if (key === "constructor" || visited.has(key)) continue;
visited.add(key);
const method = proto[key];
if (typeof method !== "function") continue;
const meta: IMcpRouteReflect | undefined = Reflect.getMetadata(
"nestia/McpRoute",
method,
);
if (!meta) continue;
const params: IMcpRouteReflect.IArgument[] =
Reflect.getMetadata("nestia/McpRoute/Parameters", proto, key) ??
[];
const paramValidator = params.find(
(p) => p.category === "params",
)?.validate;
tools.push({
meta,
source: `${wrapper.metatype?.name ?? proto.constructor?.name ?? "UnknownController"}.${String(key)}`,
validateArgs: paramValidator,
handler: async (args) => method.call(instance, args),
});
}
}
}
}
assertUniqueTools(tools);
const serverInfo = options.serverInfo ?? {
name: "nestia-mcp",
version: "1.0.0",
};
const {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
McpServer,
StreamableHTTPServerTransport,
} = await loadMcpSdk();
const http = app.getHttpAdapter();
const route = options.path ?? "/mcp";
http.all(route, async (req: any, res: any) => {
// Stateless mode requires a fresh transport per request; sharing one
// across clients races on internal initialization and request IDs.
const mcp = new McpServer(serverInfo, {
capabilities: { tools: {} },
});
const server = mcp.server;
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t) => ({
name: t.meta.name,
title: t.meta.title,
description: t.meta.description,
inputSchema: t.meta.inputSchema,
outputSchema: t.meta.outputSchema,
annotations: t.meta.annotations,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (reqMsg: any) => {
const tool = tools.find((t) => t.meta.name === reqMsg.params.name);
if (!tool)
throw new McpError(
ErrorCode.MethodNotFound,
`Tool not found: ${reqMsg.params.name}`,
);
const args = reqMsg.params.arguments ?? {};
if (tool.validateArgs) {
const err: Error | null = tool.validateArgs(args);
if (err !== null) {
const body =
err instanceof BadRequestException
? (err.getResponse() as any)
: undefined;
throw new McpError(ErrorCode.InvalidParams, err.message, {
errors: body?.errors,
path: body?.path,
expected: body?.expected,
value: body?.value,
reason: body?.reason,
});
}
}
try {
const result = await tool.handler(args);
if (result === undefined) return { content: [] };
return {
content: [
{
type: "text" as const,
text:
typeof result === "string" ? result : JSON.stringify(result),
},
],
};
} catch (e) {
if (e instanceof HttpException) {
return {
content: [{ type: "text" as const, text: e.message }],
isError: true,
};
}
throw new McpError(
ErrorCode.InternalError,
e instanceof Error ? e.message : "Internal error",
);
}
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
try {
await mcp.connect(transport);
await transport.handleRequest(req.raw ?? req, res.raw ?? res, req.body);
} finally {
await transport.close().catch(() => {});
await mcp.close().catch(() => {});
}
});
}
}
const assertUniqueTools = (tools: McpAdaptor.ITool[]): void => {
const dict: Map<string, McpAdaptor.ITool[]> = new Map();
for (const tool of tools) {
const array = dict.get(tool.meta.name) ?? [];
array.push(tool);
dict.set(tool.meta.name, array);
}
const duplicated = Array.from(dict.entries()).filter(
([, list]) => list.length > 1,
);
if (duplicated.length === 0) return;
throw new Error(
[
"Duplicated MCP tool names are not allowed.",
...duplicated.map(
([name, list]) =>
` - ${JSON.stringify(name)}: ${list.map((tool) => tool.source).join(", ")}`,
),
].join("\n"),
);
};
const loadMcpSdk = async () => {
try {
const [server, transport, types] = await Promise.all([
import("@modelcontextprotocol/sdk/server/mcp.js"),
import("@modelcontextprotocol/sdk/server/streamableHttp.js"),
import("@modelcontextprotocol/sdk/types.js"),
]);
return {
McpServer: server.McpServer,
StreamableHTTPServerTransport: transport.StreamableHTTPServerTransport,
CallToolRequestSchema: types.CallToolRequestSchema,
ErrorCode: types.ErrorCode,
ListToolsRequestSchema: types.ListToolsRequestSchema,
McpError: types.McpError,
};
} catch {
throw new Error(
"McpAdaptor.upgrade() requires @modelcontextprotocol/sdk. Install it before enabling MCP routes.",
);
}
};
export namespace McpAdaptor {
/** Configuration options for {@link McpAdaptor.upgrade}. */
export interface IOptions {
/**
* HTTP path where the MCP endpoint will be mounted.
*
* @default "/mcp"
*/
path?: string;
/**
* Identity advertised to MCP clients during the initialize handshake. Shows
* up in Claude Desktop / Cursor's MCP panel.
*
* @default { name: "nestia-mcp", version: "1.0.0" }
*/
serverInfo?: { name: string; version: string };
}
/** @internal */
export interface ITool {
meta: IMcpRouteReflect;
source: string;
handler: (args: unknown) => Promise<unknown>;
validateArgs?: (input: any) => Error | null;
}
}