@nestia/core 11.2.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/adaptors/McpAdaptor.d.ts +75 -0
  2. package/lib/adaptors/McpAdaptor.js +256 -0
  3. package/lib/adaptors/McpAdaptor.js.map +1 -0
  4. package/lib/decorators/McpRoute.d.ts +73 -0
  5. package/lib/decorators/McpRoute.js +60 -0
  6. package/lib/decorators/McpRoute.js.map +1 -0
  7. package/lib/decorators/internal/IMcpRouteReflect.d.ts +2 -0
  8. package/lib/decorators/internal/IMcpRouteReflect.js +3 -0
  9. package/lib/decorators/internal/IMcpRouteReflect.js.map +1 -0
  10. package/lib/module.d.ts +2 -0
  11. package/lib/module.js +2 -0
  12. package/lib/module.js.map +1 -1
  13. package/lib/programmers/McpRouteProgrammer.d.ts +26 -0
  14. package/lib/programmers/McpRouteProgrammer.js +56 -0
  15. package/lib/programmers/McpRouteProgrammer.js.map +1 -0
  16. package/lib/programmers/McpRouteReturnProgrammer.d.ts +24 -0
  17. package/lib/programmers/McpRouteReturnProgrammer.js +63 -0
  18. package/lib/programmers/McpRouteReturnProgrammer.js.map +1 -0
  19. package/lib/programmers/internal/check_mcp_object.d.ts +1 -0
  20. package/lib/programmers/internal/check_mcp_object.js +28 -0
  21. package/lib/programmers/internal/check_mcp_object.js.map +1 -0
  22. package/lib/transformers/McpRouteTransformer.d.ts +33 -0
  23. package/lib/transformers/McpRouteTransformer.js +182 -0
  24. package/lib/transformers/McpRouteTransformer.js.map +1 -0
  25. package/lib/transformers/MethodTransformer.js +6 -0
  26. package/lib/transformers/MethodTransformer.js.map +1 -1
  27. package/lib/transformers/ParameterDecoratorTransformer.js +3 -0
  28. package/lib/transformers/ParameterDecoratorTransformer.js.map +1 -1
  29. package/package.json +4 -3
  30. package/src/adaptors/McpAdaptor.ts +276 -0
  31. package/src/decorators/McpRoute.ts +159 -0
  32. package/src/decorators/internal/IMcpRouteReflect.ts +41 -0
  33. package/src/module.ts +2 -0
  34. package/src/programmers/McpRouteProgrammer.ts +75 -0
  35. package/src/programmers/McpRouteReturnProgrammer.ts +77 -0
  36. package/src/programmers/internal/check_mcp_object.ts +37 -0
  37. package/src/transformers/McpRouteTransformer.ts +271 -0
  38. package/src/transformers/MethodTransformer.ts +6 -0
  39. package/src/transformers/ParameterDecoratorTransformer.ts +5 -0
@@ -0,0 +1,276 @@
1
+ import {
2
+ BadRequestException,
3
+ HttpException,
4
+ INestApplication,
5
+ } from "@nestjs/common";
6
+ import { NestContainer } from "@nestjs/core";
7
+
8
+ import { IMcpRouteReflect } from "../decorators/internal/IMcpRouteReflect";
9
+
10
+ /**
11
+ * MCP (Model Context Protocol) adaptor.
12
+ *
13
+ * `McpAdaptor` exposes every method decorated with {@link McpRoute} as an MCP
14
+ * tool, reachable by LLM clients through a stateless Streamable HTTP endpoint.
15
+ *
16
+ * At bootstrap the adaptor walks the {@link NestContainer}, collects every
17
+ * controller method carrying `"nestia/McpRoute"` metadata, and caches a tool
18
+ * registry. A fresh MCP server and transport pair is spun up per incoming HTTP
19
+ * request, following MCP stateless Streamable HTTP mode. This adaptor
20
+ * intentionally does not manage `Mcp-Session-Id` state.
21
+ *
22
+ * Typia-generated JSON Schemas flow through unchanged; the Zod-based high-level
23
+ * registration API of `McpServer` is bypassed by accessing the low-level
24
+ * `.server` handler.
25
+ *
26
+ * Error mapping follows the MCP specification:
27
+ *
28
+ * - Unknown tool name: JSON-RPC `-32601`.
29
+ * - Typia validation failure: JSON-RPC `-32602` with structured diagnostics.
30
+ * - Handler throws {@link HttpException}: success response with `isError: true`,
31
+ * so the LLM can read the message and recover.
32
+ * - Any other throw: JSON-RPC `-32603`.
33
+ *
34
+ * @author wildduck - https://github.com/wildduck2
35
+ * @example
36
+ * ```typescript
37
+ * import core from "@nestia/core";
38
+ * import { NestFactory } from "@nestjs/core";
39
+ *
40
+ * const app = await NestFactory.create(AppModule);
41
+ * await core.McpAdaptor.upgrade(app, { path: "/mcp" });
42
+ * await app.listen(3000);
43
+ * ```;
44
+ */
45
+ export class McpAdaptor {
46
+ /**
47
+ * Upgrade a running Nest application with a stateless MCP endpoint.
48
+ *
49
+ * Scans the application container for methods decorated with {@link McpRoute},
50
+ * then registers a catch-all HTTP route at the configured path. Each incoming
51
+ * request builds a fresh MCP server + transport on demand, wires the
52
+ * registered tools into it, and delegates handling.
53
+ *
54
+ * Must be called after `NestFactory.create(...)` but before `app.listen(...)`
55
+ * if you want the MCP endpoint to be reachable alongside your regular HTTP
56
+ * routes.
57
+ *
58
+ * @param app Running Nest application instance.
59
+ * @param options Transport and identity overrides.
60
+ */
61
+ public static async upgrade(
62
+ app: INestApplication,
63
+ options: McpAdaptor.IOptions = {},
64
+ ): Promise<void> {
65
+ if ("sessioned" in (options as Record<string, unknown>))
66
+ throw new Error(
67
+ "McpAdaptor.upgrade() supports stateless Streamable HTTP only; sessioned mode is not implemented.",
68
+ );
69
+
70
+ const tools: McpAdaptor.ITool[] = [];
71
+ const container = (app as any).container as NestContainer;
72
+ for (const module of container.getModules().values()) {
73
+ for (const wrapper of module.controllers.values()) {
74
+ const instance = wrapper.instance;
75
+ if (!instance) continue;
76
+ const proto = Object.getPrototypeOf(instance);
77
+ for (const key of Object.getOwnPropertyNames(proto)) {
78
+ if (key === "constructor") continue;
79
+ const method = proto[key];
80
+ if (typeof method !== "function") continue;
81
+
82
+ const meta: IMcpRouteReflect | undefined = Reflect.getMetadata(
83
+ "nestia/McpRoute",
84
+ method,
85
+ );
86
+ if (!meta) continue;
87
+
88
+ const params: IMcpRouteReflect.IArgument[] =
89
+ Reflect.getMetadata("nestia/McpRoute/Parameters", proto, key) ?? [];
90
+ const paramValidator = params.find(
91
+ (p) => p.category === "params",
92
+ )?.validate;
93
+
94
+ tools.push({
95
+ meta,
96
+ source: `${wrapper.metatype?.name ?? proto.constructor?.name ?? "UnknownController"}.${String(key)}`,
97
+ validateArgs: paramValidator,
98
+ handler: async (args) => method.call(instance, args),
99
+ });
100
+ }
101
+ }
102
+ }
103
+ assertUniqueTools(tools);
104
+
105
+ const serverInfo = options.serverInfo ?? {
106
+ name: "nestia-mcp",
107
+ version: "1.0.0",
108
+ };
109
+ const {
110
+ CallToolRequestSchema,
111
+ ErrorCode,
112
+ ListToolsRequestSchema,
113
+ McpError,
114
+ McpServer,
115
+ StreamableHTTPServerTransport,
116
+ } = await loadMcpSdk();
117
+
118
+ const http = app.getHttpAdapter();
119
+ const route = options.path ?? "/mcp";
120
+ http.all(route, async (req: any, res: any) => {
121
+ // Stateless mode requires a fresh transport per request; sharing one
122
+ // across clients races on internal initialization and request IDs.
123
+ const mcp = new McpServer(serverInfo, {
124
+ capabilities: { tools: {} },
125
+ });
126
+ const server = mcp.server;
127
+
128
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
129
+ tools: tools.map((t) => ({
130
+ name: t.meta.name,
131
+ title: t.meta.title,
132
+ description: t.meta.description,
133
+ inputSchema: t.meta.inputSchema,
134
+ outputSchema: t.meta.outputSchema,
135
+ annotations: t.meta.annotations,
136
+ })),
137
+ }));
138
+
139
+ server.setRequestHandler(CallToolRequestSchema, async (reqMsg) => {
140
+ const tool = tools.find((t) => t.meta.name === reqMsg.params.name);
141
+ if (!tool)
142
+ throw new McpError(
143
+ ErrorCode.MethodNotFound,
144
+ `Tool not found: ${reqMsg.params.name}`,
145
+ );
146
+
147
+ const args = reqMsg.params.arguments ?? {};
148
+ if (tool.validateArgs) {
149
+ const err: Error | null = tool.validateArgs(args);
150
+ if (err !== null) {
151
+ const body =
152
+ err instanceof BadRequestException
153
+ ? (err.getResponse() as any)
154
+ : undefined;
155
+ throw new McpError(ErrorCode.InvalidParams, err.message, {
156
+ errors: body?.errors,
157
+ path: body?.path,
158
+ expected: body?.expected,
159
+ value: body?.value,
160
+ reason: body?.reason,
161
+ });
162
+ }
163
+ }
164
+
165
+ try {
166
+ const result = await tool.handler(args);
167
+ if (result === undefined) return { content: [] };
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text:
173
+ typeof result === "string" ? result : JSON.stringify(result),
174
+ },
175
+ ],
176
+ };
177
+ } catch (e) {
178
+ if (e instanceof HttpException) {
179
+ return {
180
+ content: [{ type: "text" as const, text: e.message }],
181
+ isError: true,
182
+ };
183
+ }
184
+ throw new McpError(
185
+ ErrorCode.InternalError,
186
+ e instanceof Error ? e.message : "Internal error",
187
+ );
188
+ }
189
+ });
190
+
191
+ const transport = new StreamableHTTPServerTransport({
192
+ sessionIdGenerator: undefined,
193
+ enableJsonResponse: true,
194
+ });
195
+ try {
196
+ await mcp.connect(transport);
197
+ await transport.handleRequest(req.raw ?? req, res.raw ?? res, req.body);
198
+ } finally {
199
+ await transport.close().catch(() => {});
200
+ await mcp.close().catch(() => {});
201
+ }
202
+ });
203
+ }
204
+ }
205
+
206
+ const assertUniqueTools = (tools: McpAdaptor.ITool[]): void => {
207
+ const dict: Map<string, McpAdaptor.ITool[]> = new Map();
208
+ for (const tool of tools) {
209
+ const array = dict.get(tool.meta.name) ?? [];
210
+ array.push(tool);
211
+ dict.set(tool.meta.name, array);
212
+ }
213
+ const duplicated = Array.from(dict.entries()).filter(
214
+ ([, list]) => list.length > 1,
215
+ );
216
+ if (duplicated.length === 0) return;
217
+ throw new Error(
218
+ [
219
+ "Duplicated MCP tool names are not allowed.",
220
+ ...duplicated.map(
221
+ ([name, list]) =>
222
+ ` - ${JSON.stringify(name)}: ${list.map((tool) => tool.source).join(", ")}`,
223
+ ),
224
+ ].join("\n"),
225
+ );
226
+ };
227
+
228
+ const loadMcpSdk = async () => {
229
+ try {
230
+ const [server, transport, types] = await Promise.all([
231
+ import("@modelcontextprotocol/sdk/server/mcp.js"),
232
+ import("@modelcontextprotocol/sdk/server/streamableHttp.js"),
233
+ import("@modelcontextprotocol/sdk/types.js"),
234
+ ]);
235
+ return {
236
+ McpServer: server.McpServer,
237
+ StreamableHTTPServerTransport: transport.StreamableHTTPServerTransport,
238
+ CallToolRequestSchema: types.CallToolRequestSchema,
239
+ ErrorCode: types.ErrorCode,
240
+ ListToolsRequestSchema: types.ListToolsRequestSchema,
241
+ McpError: types.McpError,
242
+ };
243
+ } catch {
244
+ throw new Error(
245
+ "McpAdaptor.upgrade() requires @modelcontextprotocol/sdk. Install it before enabling MCP routes.",
246
+ );
247
+ }
248
+ };
249
+
250
+ export namespace McpAdaptor {
251
+ /** Configuration options for {@link McpAdaptor.upgrade}. */
252
+ export interface IOptions {
253
+ /**
254
+ * HTTP path where the MCP endpoint will be mounted.
255
+ *
256
+ * @default "/mcp"
257
+ */
258
+ path?: string;
259
+
260
+ /**
261
+ * Identity advertised to MCP clients during the initialize handshake. Shows
262
+ * up in Claude Desktop / Cursor's MCP panel.
263
+ *
264
+ * @default { name: "nestia-mcp", version: "1.0.0" }
265
+ */
266
+ serverInfo?: { name: string; version: string };
267
+ }
268
+
269
+ /** @internal */
270
+ export interface ITool {
271
+ meta: IMcpRouteReflect;
272
+ source: string;
273
+ handler: (args: unknown) => Promise<unknown>;
274
+ validateArgs?: (input: any) => Error | null;
275
+ }
276
+ }
@@ -0,0 +1,159 @@
1
+ import { IRequestBodyValidator } from "../options/IRequestBodyValidator";
2
+ import { IMcpRouteReflect } from "./internal/IMcpRouteReflect";
3
+ import { validate_request_body } from "./internal/validate_request_body";
4
+
5
+ /**
6
+ * MCP (Model Context Protocol) route decorator.
7
+ *
8
+ * `@McpRoute()` marks a controller method as a callable MCP tool. When the
9
+ * application bootstraps, every method annotated with this decorator is
10
+ * registered on the MCP server built by {@link McpAdaptor.upgrade}, making it
11
+ * reachable by LLM clients (Claude Desktop, Cursor, OpenAI function calling,
12
+ * etc.) through the standard Streamable HTTP transport.
13
+ *
14
+ * The public form takes only the tool's `name` (string). Human-readable
15
+ * `description` and `title` are read from the method's JSDoc:
16
+ *
17
+ * - `description`: the JSDoc comment body.
18
+ * - `title`: the value of an optional `@title` JSDoc tag.
19
+ *
20
+ * For type-safe tool inputs, decorate exactly one parameter of the method with
21
+ * {@link McpRoute.Params}. The parameter type `T` is analyzed at compile time by
22
+ * the nestia transformer, which generates both a runtime validator (powered by
23
+ * typia) and the JSON Schema attached to `inputSchema` in `tools/list`
24
+ * responses.
25
+ *
26
+ * For the MCP endpoint to actually be served, you must call
27
+ * {@link McpAdaptor.upgrade} on the {@link INestApplication} instance at
28
+ * bootstrap. The decorator alone only stores reflection metadata.
29
+ *
30
+ * @author wildduck - https://github.com/wildduck2
31
+ * @example
32
+ * ```typescript
33
+ * import core from "@nestia/core";
34
+ *
35
+ * @Controller()
36
+ * export class WeatherController {
37
+ * /**
38
+ * * Return current weather for a city.
39
+ * *
40
+ * * @title Get weather
41
+ * *\/
42
+ * @core.McpRoute("get_weather")
43
+ * public async get(
44
+ * @core.McpRoute.Params() params: { city: string },
45
+ * ): Promise<{ temp: number }> {
46
+ * return { temp: 22 };
47
+ * }
48
+ * }
49
+ * ```;
50
+ *
51
+ * @param name Unique tool identifier exposed to MCP clients via `tools/list`.
52
+ * @returns Method decorator.
53
+ */
54
+ export function McpRoute(name: string): MethodDecorator;
55
+
56
+ /** @internal */
57
+ export function McpRoute(config: McpRoute.IConfig): MethodDecorator;
58
+
59
+ export function McpRoute(input: string | McpRoute.IConfig): MethodDecorator {
60
+ const config: McpRoute.IConfig =
61
+ typeof input === "string" ? { name: input } : input;
62
+ return function McpRoute(
63
+ _target: Object,
64
+ _propertyKey: string | symbol,
65
+ descriptor: TypedPropertyDescriptor<any>,
66
+ ): TypedPropertyDescriptor<any> {
67
+ Reflect.defineMetadata(
68
+ "nestia/McpRoute",
69
+ {
70
+ name: config.name,
71
+ title: config.title,
72
+ description: config.description,
73
+ inputSchema: config.inputSchema ?? { type: "object", properties: {} },
74
+ outputSchema: config.outputSchema,
75
+ annotations: config.annotations,
76
+ } satisfies IMcpRouteReflect,
77
+ descriptor.value,
78
+ );
79
+ return descriptor;
80
+ };
81
+ }
82
+
83
+ export namespace McpRoute {
84
+ /**
85
+ * Configuration object emitted by the nestia transformer at compile time.
86
+ *
87
+ * Users do not write this directly; they call `@McpRoute("name")` and the
88
+ * transformer rewrites the call to `@McpRoute({ name, description, title,
89
+ * inputSchema, ... })` after parsing the method's JSDoc and analyzing the
90
+ * `@McpRoute.Params<T>()` parameter type.
91
+ *
92
+ * @internal
93
+ */
94
+ export interface IConfig {
95
+ name: string;
96
+ title?: string;
97
+ description?: string;
98
+ inputSchema?: object;
99
+ outputSchema?: object;
100
+ annotations?: IMcpRouteReflect["annotations"];
101
+ }
102
+
103
+ /**
104
+ * Parameter decorator for an MCP tool's input arguments.
105
+ *
106
+ * `@McpRoute.Params<T>()` validates the `arguments` object from a
107
+ * `tools/call` request against the TypeScript type `T` using typia. A failed
108
+ * validation surfaces to the client as a JSON-RPC `-32602` (`InvalidParams`)
109
+ * error with structured diagnostics, giving the LLM precise feedback to
110
+ * self-correct.
111
+ *
112
+ * MCP tools accept exactly one arguments object; applying this decorator more
113
+ * than once on a single method is a compile-time error. The decorated type
114
+ * `T` must be an object type without dynamic properties (no `Record<string,
115
+ * X>`, no index signatures); the nestia transformer enforces this through
116
+ * `LlmSchemaProgrammer.validate`.
117
+ *
118
+ * @author wildduck - https://github.com/wildduck2
119
+ * @param validator Optional custom validator. Default is `typia.assert()`.
120
+ * @returns Parameter decorator.
121
+ */
122
+ export function Params<T>(
123
+ validator?: IRequestBodyValidator<T>,
124
+ ): ParameterDecorator {
125
+ const validate = validate_request_body("McpRoute.Params")(validator);
126
+ return function McpRouteParams(
127
+ target: Object,
128
+ propertyKey: string | symbol | undefined,
129
+ parameterIndex: number,
130
+ ) {
131
+ emplace(target, propertyKey ?? "", {
132
+ category: "params",
133
+ index: parameterIndex,
134
+ validate,
135
+ });
136
+ };
137
+ }
138
+
139
+ /** @internal */
140
+ const emplace = (
141
+ target: Object,
142
+ propertyKey: string | symbol,
143
+ value: IMcpRouteReflect.IArgument,
144
+ ) => {
145
+ const array: IMcpRouteReflect.IArgument[] | undefined = Reflect.getMetadata(
146
+ "nestia/McpRoute/Parameters",
147
+ target,
148
+ propertyKey,
149
+ );
150
+ if (array !== undefined) array.push(value);
151
+ else
152
+ Reflect.defineMetadata(
153
+ "nestia/McpRoute/Parameters",
154
+ [value],
155
+ target,
156
+ propertyKey,
157
+ );
158
+ };
159
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reflection metadata stored by the {@link McpRoute} decorator.
3
+ *
4
+ * Every method decorated with `@McpRoute()` carries an `IMcpRouteReflect`
5
+ * object under the `"nestia/McpRoute"` key on its function value. The
6
+ * {@link McpAdaptor} reads these at bootstrap to build the MCP tool registry.
7
+ *
8
+ * @author wildduck - https://github.com/wildduck2
9
+ * @internal
10
+ */
11
+ export interface IMcpRouteReflect {
12
+ name: string;
13
+ title?: string;
14
+ description?: string;
15
+ inputSchema: object;
16
+ outputSchema?: object;
17
+ annotations?: {
18
+ readOnlyHint?: boolean;
19
+ destructiveHint?: boolean;
20
+ idempotentHint?: boolean;
21
+ openWorldHint?: boolean;
22
+ };
23
+ }
24
+
25
+ export namespace IMcpRouteReflect {
26
+ /**
27
+ * Per-parameter reflection entry stored under `"nestia/McpRoute/Parameters"`
28
+ * on the owning prototype + property key.
29
+ *
30
+ * `validate` is the closure returned by `validate_request_body(...)`;
31
+ * runtime callers receive `null` for valid input or an `Error` for invalid
32
+ * input.
33
+ *
34
+ * @internal
35
+ */
36
+ export interface IArgument {
37
+ category: "params";
38
+ index: number;
39
+ validate: (input: any) => Error | null;
40
+ }
41
+ }
package/src/module.ts CHANGED
@@ -20,4 +20,6 @@ export * from "./decorators/SwaggerExample";
20
20
 
21
21
  export * from "./adaptors/WebSocketAdaptor";
22
22
  export * from "./decorators/WebSocketRoute";
23
+ export * from "./adaptors/McpAdaptor";
24
+ export * from "./decorators/McpRoute";
23
25
  export * from "./decorators/doNotThrowTransformError";
@@ -0,0 +1,75 @@
1
+ import {
2
+ LlmParametersProgrammer,
3
+ MetadataCollection,
4
+ MetadataFactory,
5
+ MetadataSchema,
6
+ TransformerError,
7
+ } from "@typia/core";
8
+ import { ValidationPipe } from "@typia/interface";
9
+ import ts from "typescript";
10
+
11
+ import { INestiaTransformContext } from "../options/INestiaTransformProject";
12
+ import { check_mcp_object } from "./internal/check_mcp_object";
13
+
14
+ /**
15
+ * Compile-time JSON Schema emitter for `@McpRoute.Params<T>()` types.
16
+ *
17
+ * Mirrors {@link TypedBodyProgrammer} for runtime validators, but instead of
18
+ * emitting a `typia.assert` closure it emits a literal JSON Schema object.
19
+ * The MCP specification requires `inputSchema` to be a single static object
20
+ * type — no primitives, no unions, no dynamic-key records — so this
21
+ * programmer rejects anything else with a {@link TransformerError}.
22
+ *
23
+ * The emitted shape is `LlmParametersProgrammer`'s parameters block —
24
+ * `{ type: "object", properties, required, $defs }` — which is exactly what
25
+ * the MCP `tools/list` response requires for `inputSchema`. Plain
26
+ * `JsonSchemaProgrammer.write` cannot be used here because it may produce a
27
+ * top-level `$ref` to a `$defs` entry, which MCP clients reject.
28
+ *
29
+ * @author wildduck - https://github.com/wildduck2
30
+ */
31
+ export namespace McpRouteProgrammer {
32
+ export const generate = (props: {
33
+ context: INestiaTransformContext;
34
+ modulo: ts.LeftHandSideExpression;
35
+ type: ts.Type;
36
+ }): ts.Expression => {
37
+ const result: ValidationPipe<MetadataSchema, MetadataFactory.IError> =
38
+ MetadataFactory.analyze({
39
+ checker: props.context.checker,
40
+ transformer: props.context.transformer,
41
+ options: {
42
+ escape: true,
43
+ constant: true,
44
+ absorb: true,
45
+ },
46
+ components: new MetadataCollection(),
47
+ type: props.type,
48
+ });
49
+ if (result.success === false)
50
+ throw TransformerError.from({
51
+ code: "@nestia.core.McpRoute.Params",
52
+ errors: result.errors,
53
+ });
54
+
55
+ const violations: string[] = check_mcp_object("params")(result.data);
56
+ if (violations.length)
57
+ throw new Error(
58
+ `[@nestia.core.McpRoute.Params] ${violations.join(" ")}`,
59
+ );
60
+
61
+ return LlmParametersProgrammer.write({
62
+ context: {
63
+ ...props.context,
64
+ options: {
65
+ numeric: false,
66
+ finite: false,
67
+ functional: false,
68
+ },
69
+ },
70
+ config: { strict: false },
71
+ metadata: result.data,
72
+ });
73
+ };
74
+
75
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ MetadataCollection,
3
+ MetadataFactory,
4
+ MetadataSchema,
5
+ TransformerError,
6
+ } from "@typia/core";
7
+ import { ValidationPipe } from "@typia/interface";
8
+ import ts from "typescript";
9
+
10
+ import { INestiaTransformContext } from "../options/INestiaTransformProject";
11
+ import { check_mcp_object } from "./internal/check_mcp_object";
12
+
13
+ /**
14
+ * Compile-time validator for the return type of an `@McpRoute()` method.
15
+ *
16
+ * Mirrors {@link TypedRouteProgrammer} but adapted to MCP's structured-output
17
+ * rules. The MCP specification requires every tool's output to be a single
18
+ * object value or nothing at all. To enforce that:
19
+ *
20
+ * - `void` returns short-circuit (nothing emitted).
21
+ * - Object returns are inspected directly on the analyzed metadata to reject
22
+ * primitives, unions, and dynamic-key records.
23
+ * - Mixed unions of `void` and a value type are rejected outright; MCP has no
24
+ * way to model "sometimes returns nothing, sometimes returns X".
25
+ *
26
+ * @author wildduck - https://github.com/wildduck2
27
+ */
28
+ export namespace McpRouteReturnProgrammer {
29
+ export const generate = (props: {
30
+ context: INestiaTransformContext;
31
+ modulo: ts.LeftHandSideExpression;
32
+ type: ts.Type;
33
+ }): void => {
34
+ const inner: ts.Type =
35
+ props.context.checker.getAwaitedType(props.type) ?? props.type;
36
+ if (inner.isUnion()) {
37
+ const hasVoid = inner.types.some((t) => isVoidLike(t));
38
+ const hasValue = inner.types.some((t) => !isVoidLike(t));
39
+ if (hasVoid && hasValue)
40
+ throw new Error(
41
+ "[@nestia.core.McpRoute] tool return type must be either `void` or a single object type. Mixed unions of `void` and an object type are not supported.",
42
+ );
43
+ }
44
+ if (isVoidLike(inner)) return;
45
+
46
+ const result: ValidationPipe<MetadataSchema, MetadataFactory.IError> =
47
+ MetadataFactory.analyze({
48
+ checker: props.context.checker,
49
+ transformer: props.context.transformer,
50
+ options: {
51
+ escape: true,
52
+ constant: true,
53
+ absorb: true,
54
+ },
55
+ components: new MetadataCollection(),
56
+ type: inner,
57
+ });
58
+ if (result.success === false)
59
+ throw TransformerError.from({
60
+ code: "@nestia.core.McpRoute.Return",
61
+ errors: result.errors,
62
+ });
63
+
64
+ if (result.data.size() === 0) return;
65
+ const violations: string[] = check_mcp_object("return")(result.data);
66
+ if (violations.length)
67
+ throw new Error(
68
+ `[@nestia.core.McpRoute.Return] ${violations.join(" ")}`,
69
+ );
70
+ };
71
+
72
+ const isVoidLike = (t: ts.Type): boolean =>
73
+ Boolean(
74
+ t.flags &
75
+ (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never),
76
+ );
77
+ }
@@ -0,0 +1,37 @@
1
+ import { MetadataSchema } from "@typia/core";
2
+
3
+ /**
4
+ * Shared object-only constraint check for MCP parameter and return schemas.
5
+ *
6
+ * MCP requires both `inputSchema` and `outputSchema` (when present) to be a
7
+ * single, static object type — no primitives, no unions, no dynamic-key
8
+ * records. Parameter types additionally cannot be empty (a tool must accept
9
+ * an object); return types may be empty (`void` short-circuited upstream).
10
+ *
11
+ * @internal
12
+ * @author wildduck - https://github.com/wildduck2
13
+ */
14
+ export const check_mcp_object =
15
+ (kind: "params" | "return") =>
16
+ (metadata: MetadataSchema): string[] => {
17
+ const errors: string[] = [];
18
+ if (metadata.objects.length === 0)
19
+ errors.push(
20
+ kind === "return"
21
+ ? "MCP tool return type must be an object type or `void`."
22
+ : "MCP tool parameters must be an object type.",
23
+ );
24
+ else if (metadata.objects.length > 1 || metadata.size() > 1)
25
+ errors.push(
26
+ `MCP tool ${kind === "return" ? "return type" : "parameters"} must be a single object type.`,
27
+ );
28
+ else if (
29
+ metadata.objects[0]!.type.properties.some(
30
+ (p) => p.key.isSoleLiteral() === false,
31
+ )
32
+ )
33
+ errors.push(
34
+ `MCP tool ${kind === "return" ? "return type" : "parameters"} must not declare dynamic keys (Record<string, X>, index signatures).`,
35
+ );
36
+ return errors;
37
+ };