UNPKG

@nestia/core

Version:

Super-fast validation decorators of NestJS

285 lines (266 loc) 9.53 kB
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; } }