@silkweave/nestjs 1.12.0 → 2.0.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.
- package/README.md +98 -165
- package/build/index.d.mts +218 -267
- package/build/index.d.mts.map +1 -1
- package/build/index.mjs +690 -509
- package/build/index.mjs.map +1 -1
- package/package.json +9 -10
- package/src/decorator/mcp.ts +39 -0
- package/src/index.ts +5 -9
- package/src/lib/controllerDiscovery.ts +201 -0
- package/src/lib/metadata.ts +32 -52
- package/src/lib/rebind.ts +117 -0
- package/src/lib/reflect/classValidator.ts +56 -0
- package/src/lib/reflect/openapi.ts +87 -0
- package/src/lib/reflect/params.ts +63 -0
- package/src/lib/reflect/route.ts +58 -0
- package/src/lib/reflect/schema.ts +270 -0
- package/src/lib/reflect/swagger.ts +39 -0
- package/src/lib/silkweave.module.ts +17 -17
- package/src/lib/types.ts +13 -10
- package/tsconfig.tsbuildinfo +1 -1
- package/src/adapter/rest.ts +0 -173
- package/src/adapter/trpc.ts +0 -58
- package/src/adapter/typegen.ts +0 -55
- package/src/decorator/action.ts +0 -38
- package/src/decorator/actions.ts +0 -25
- package/src/lib/discovery.ts +0 -131
- package/src/lib/filter.ts +0 -26
- package/src/lib/openapi.ts +0 -116
- package/src/lib/swagger.ts +0 -74
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@silkweave/nestjs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Silkweave NestJS Adapter",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,25 +12,24 @@
|
|
|
12
12
|
"peerDependencies": {
|
|
13
13
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
|
14
14
|
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
|
15
|
-
"@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0"
|
|
15
|
+
"@nestjs/swagger": "^7.0.0 || ^8.0.0 || ^11.0.0",
|
|
16
|
+
"class-validator": "^0.14.0"
|
|
16
17
|
},
|
|
17
18
|
"peerDependenciesMeta": {
|
|
18
19
|
"@nestjs/swagger": {
|
|
19
20
|
"optional": true
|
|
21
|
+
},
|
|
22
|
+
"class-validator": {
|
|
23
|
+
"optional": true
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"@trpc/server": "^11.7.1",
|
|
24
|
-
"change-case": "^5.4.4",
|
|
25
27
|
"cors": "^2.8.6",
|
|
26
28
|
"express": "^5.2.1",
|
|
27
29
|
"zod": "^3.25.0",
|
|
28
|
-
"@silkweave/auth": "
|
|
29
|
-
"@silkweave/core": "
|
|
30
|
-
"@silkweave/mcp": "
|
|
31
|
-
"@silkweave/trpc": "1.12.0",
|
|
32
|
-
"@silkweave/typegen": "1.12.0",
|
|
33
|
-
"@silkweave/logger": "1.12.0"
|
|
30
|
+
"@silkweave/auth": "2.0.0",
|
|
31
|
+
"@silkweave/core": "2.0.0",
|
|
32
|
+
"@silkweave/mcp": "2.0.0"
|
|
34
33
|
},
|
|
35
34
|
"devDependencies": {
|
|
36
35
|
"@eslint/js": "^10.0.1",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common'
|
|
2
|
+
import { MCP_METADATA, type McpMetadata } from '../lib/metadata.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Method decorator that exposes an existing NestJS controller route as an MCP
|
|
6
|
+
* tool. It is **additive** - the route keeps serving HTTP exactly as before;
|
|
7
|
+
* `@Mcp()` just opts the method into MCP discovery.
|
|
8
|
+
*
|
|
9
|
+
* The tool's name, description, and input schema are reflected from the
|
|
10
|
+
* method's own metadata:
|
|
11
|
+
* - **fields** from the parameter decorators (`@Param`/`@Query`/`@Body`) - a
|
|
12
|
+
* `@Param('id')` becomes an `id` field; a whole-DTO `@Body() dto: CreateDto`
|
|
13
|
+
* is flattened to its properties,
|
|
14
|
+
* - **types/constraints/descriptions** from `@nestjs/swagger`
|
|
15
|
+
* (`@ApiParam`/`@ApiQuery`/`@ApiProperty`/`@ApiOperation`) and, when present,
|
|
16
|
+
* `class-validator` decorators on the DTOs,
|
|
17
|
+
* - optionally refined by an OpenAPI document passed to `SilkweaveModule`.
|
|
18
|
+
*
|
|
19
|
+
* On a tool call the input is split back into the method's positional arguments
|
|
20
|
+
* and the method is invoked directly (with `@UseGuards` guards applied first).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* @Controller('sessions/:sessionId/channels')
|
|
25
|
+
* export class ChannelsController {
|
|
26
|
+
* @Get(':channelId')
|
|
27
|
+
* @ApiOperation({ summary: 'Get a specific channel by ID' })
|
|
28
|
+
* @ApiParam({ name: 'sessionId', description: 'Session ID' })
|
|
29
|
+
* @ApiParam({ name: 'channelId', description: 'Channel ID' })
|
|
30
|
+
* @Mcp()
|
|
31
|
+
* findOne(@Param('sessionId') sessionId: string, @Param('channelId') channelId: string) {
|
|
32
|
+
* return this.service.get(sessionId, channelId)
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function Mcp(options: McpMetadata = {}): MethodDecorator {
|
|
38
|
+
return SetMetadata(MCP_METADATA, options)
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
export * from './adapter/mcp.js'
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './
|
|
4
|
-
export * from './adapter/typegen.js'
|
|
5
|
-
export * from './decorator/action.js'
|
|
6
|
-
export * from './decorator/actions.js'
|
|
7
|
-
export * from './lib/discovery.js'
|
|
8
|
-
export * from './lib/filter.js'
|
|
2
|
+
export * from './decorator/mcp.js'
|
|
3
|
+
export * from './lib/controllerDiscovery.js'
|
|
9
4
|
export * from './lib/guards.js'
|
|
10
5
|
export * from './lib/metadata.js'
|
|
11
|
-
export * from './lib/
|
|
6
|
+
export * from './lib/rebind.js'
|
|
12
7
|
export * from './lib/silkweave.module.js'
|
|
13
|
-
export * from './lib/swagger.js'
|
|
14
8
|
export * from './lib/types.js'
|
|
9
|
+
export * from './lib/reflect/openapi.js'
|
|
10
|
+
export * from './lib/reflect/schema.js'
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
2
|
+
import { Injectable, type Type } from '@nestjs/common'
|
|
3
|
+
import { DiscoveryService, MetadataScanner, ModuleRef, Reflector } from '@nestjs/core'
|
|
4
|
+
import { createAction, type Action, type SilkweaveContext } from '@silkweave/core'
|
|
5
|
+
import { z } from 'zod/v4'
|
|
6
|
+
import { collectGuards, runGuards } from './guards.js'
|
|
7
|
+
import { MCP_METADATA, type McpMetadata } from './metadata.js'
|
|
8
|
+
import { invokeRebound, specialBinding, type Binding } from './rebind.js'
|
|
9
|
+
import { buildOpenApiLookup, openApiFields, type OpenApiDocument, type OpenApiLookup } from './reflect/openapi.js'
|
|
10
|
+
import { PARAMTYPE, type ParamSlot, readParamSlots } from './reflect/params.js'
|
|
11
|
+
import { reflectRoute } from './reflect/route.js'
|
|
12
|
+
import { type FieldDesc, fieldToZod, mergeField, reflectDtoFields } from './reflect/schema.js'
|
|
13
|
+
import { reflectOperation } from './reflect/swagger.js'
|
|
14
|
+
|
|
15
|
+
interface DiscoveredMcp {
|
|
16
|
+
instance: object
|
|
17
|
+
classRef: Type<unknown>
|
|
18
|
+
method: (...args: unknown[]) => unknown
|
|
19
|
+
methodName: string
|
|
20
|
+
meta: McpMetadata
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BuiltInput {
|
|
24
|
+
shape: Record<string, z.ZodType>
|
|
25
|
+
bindings: Binding[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Injectable()
|
|
29
|
+
export class ControllerDiscovery {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly discovery: DiscoveryService,
|
|
32
|
+
private readonly scanner: MetadataScanner,
|
|
33
|
+
private readonly reflector: Reflector,
|
|
34
|
+
private readonly moduleRef: ModuleRef
|
|
35
|
+
) { }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk every Nest provider/controller, find methods annotated with `@Mcp`,
|
|
39
|
+
* and build a core `Action` per method whose input schema is reflected from
|
|
40
|
+
* the route + parameter decorators (+ optional OpenAPI document) and whose
|
|
41
|
+
* `run` re-binds the validated input back into the method's positional
|
|
42
|
+
* arguments (with `@UseGuards` guards applied first).
|
|
43
|
+
*/
|
|
44
|
+
discover(openapi?: OpenApiDocument): Action[] {
|
|
45
|
+
const lookup = openapi ? buildOpenApiLookup(openapi) : undefined
|
|
46
|
+
const discovered: DiscoveredMcp[] = []
|
|
47
|
+
for (const wrapper of this.discovery.getProviders().concat(this.discovery.getControllers())) {
|
|
48
|
+
const { instance } = wrapper
|
|
49
|
+
if (!instance || typeof instance !== 'object') { continue }
|
|
50
|
+
const proto = Object.getPrototypeOf(instance) as object | null
|
|
51
|
+
if (!proto) { continue }
|
|
52
|
+
const classRef = instance.constructor as Type<unknown>
|
|
53
|
+
for (const methodName of this.scanner.getAllMethodNames(proto)) {
|
|
54
|
+
const method = (proto as Record<string, unknown>)[methodName] as ((...args: unknown[]) => unknown) | undefined
|
|
55
|
+
if (typeof method !== 'function') { continue }
|
|
56
|
+
const meta = this.reflector.get<McpMetadata>(MCP_METADATA, method)
|
|
57
|
+
if (!meta) { continue }
|
|
58
|
+
discovered.push({ instance, classRef, method, methodName, meta })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return discovered.map((d) => this.toAction(d, lookup))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private toAction(d: DiscoveredMcp, lookup: OpenApiLookup | undefined): Action {
|
|
65
|
+
const proto = Object.getPrototypeOf(d.instance) as object
|
|
66
|
+
const route = reflectRoute(d.classRef, d.method)
|
|
67
|
+
const slots = readParamSlots(d.classRef, d.methodName, proto)
|
|
68
|
+
const operation = reflectOperation(d.method)
|
|
69
|
+
const docFields = lookup ? openApiFields(lookup, route.method, route.openapiPath) : {}
|
|
70
|
+
|
|
71
|
+
const { shape, bindings } = this.buildInput(d, route.pathParams, slots, operation.params, docFields)
|
|
72
|
+
|
|
73
|
+
const base = d.classRef.name.replace(/Controller$/, '')
|
|
74
|
+
const name = d.meta.name ?? `${base}.${d.methodName}`
|
|
75
|
+
const description = d.meta.description ?? operation.description ?? `${d.methodName} (${route.method} /${route.path})`
|
|
76
|
+
const applyParamPipes = d.meta.pipes !== 'skip'
|
|
77
|
+
|
|
78
|
+
const guards = collectGuards(this.reflector, d.classRef, d.method)
|
|
79
|
+
const { moduleRef, reflector } = this
|
|
80
|
+
const classRef = d.classRef
|
|
81
|
+
const method = d.method
|
|
82
|
+
const instance = d.instance
|
|
83
|
+
|
|
84
|
+
const applyGuards = async (context: SilkweaveContext): Promise<void> => {
|
|
85
|
+
if (guards.length === 0) { return }
|
|
86
|
+
const request = context.getOptional<unknown>('request')
|
|
87
|
+
const response = context.getOptional<unknown>('response') ?? null
|
|
88
|
+
const hasRequest = request != null
|
|
89
|
+
const guardRequest = hasRequest ? request : { headers: {}, params: {}, query: {} }
|
|
90
|
+
await runGuards(guards, moduleRef, reflector, classRef, method, guardRequest, response, hasRequest ? 'http' : 'rpc')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return createAction({
|
|
94
|
+
name,
|
|
95
|
+
description,
|
|
96
|
+
input: z.object(shape),
|
|
97
|
+
// Only the MCP adapter exposes `@Mcp` methods; a future `@Trpc` decorator
|
|
98
|
+
// would tag its own actions for the tRPC adapter.
|
|
99
|
+
isEnabled: (ctx) => ctx.getOptional<string>('adapter') === 'mcp',
|
|
100
|
+
run: async (input: object, context: SilkweaveContext): Promise<object> => {
|
|
101
|
+
await applyGuards(context)
|
|
102
|
+
const request = context.getOptional<{ headers?: Record<string, unknown> }>('request')
|
|
103
|
+
const result = await invokeRebound(method, instance, input as Record<string, unknown>, bindings, request, applyParamPipes)
|
|
104
|
+
return (result ?? {})
|
|
105
|
+
}
|
|
106
|
+
}) as Action
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build the merged Zod input shape and the per-argument re-bind plan. */
|
|
110
|
+
private buildInput(
|
|
111
|
+
d: DiscoveredMcp,
|
|
112
|
+
pathParams: string[],
|
|
113
|
+
slots: ReturnType<typeof readParamSlots>,
|
|
114
|
+
operationParams: Record<string, FieldDesc>,
|
|
115
|
+
docFields: Record<string, FieldDesc>
|
|
116
|
+
): BuiltInput {
|
|
117
|
+
const proto = Object.getPrototypeOf(d.instance) as object
|
|
118
|
+
const designTypes = (Reflect.getMetadata('design:paramtypes', proto, d.methodName) as unknown[] | undefined) ?? []
|
|
119
|
+
const fields: Record<string, FieldDesc> = {}
|
|
120
|
+
const maxIndex = slots.reduce((m, s) => Math.max(m, s.index), -1)
|
|
121
|
+
const bindings: Binding[] = Array.from({ length: maxIndex + 1 }, () => ({ kind: 'missing' as const }))
|
|
122
|
+
|
|
123
|
+
const addField = (name: string, desc: FieldDesc): void => {
|
|
124
|
+
fields[name] = name in fields ? mergeField(fields[name], desc) : desc
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const slot of slots) {
|
|
128
|
+
const { binding, fields: contributed } = contributeSlot(slot, pathParams, designTypes)
|
|
129
|
+
bindings[slot.index] = binding
|
|
130
|
+
for (const [name, desc] of Object.entries(contributed)) { addField(name, desc) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Layer operation-level (`@ApiParam`/`@ApiQuery`) then OpenAPI-document
|
|
134
|
+
// metadata over the structural fields (later sources win per field).
|
|
135
|
+
for (const [name, desc] of Object.entries(operationParams)) {
|
|
136
|
+
if (name in fields) { fields[name] = mergeField(fields[name], desc) }
|
|
137
|
+
}
|
|
138
|
+
for (const [name, desc] of Object.entries(docFields)) {
|
|
139
|
+
if (name in fields) { fields[name] = mergeField(fields[name], desc) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const shape: Record<string, z.ZodType> = {}
|
|
143
|
+
for (const [name, desc] of Object.entries(fields)) { shape[name] = fieldToZod(desc) }
|
|
144
|
+
// `@Mcp({ input })` raw-shape override wins per field.
|
|
145
|
+
Object.assign(shape, d.meta.input ?? {})
|
|
146
|
+
|
|
147
|
+
return { shape, bindings }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function designTypeAt(designTypes: unknown[], index: number): FieldDesc {
|
|
152
|
+
const ctor = designTypes[index]
|
|
153
|
+
if (ctor === String) { return { type: 'string' } }
|
|
154
|
+
if (ctor === Number) { return { type: 'number' } }
|
|
155
|
+
if (ctor === Boolean) { return { type: 'boolean' } }
|
|
156
|
+
return {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface SlotContribution {
|
|
160
|
+
binding: Binding
|
|
161
|
+
/** Input fields this slot contributes, keyed by field name. */
|
|
162
|
+
fields: Record<string, FieldDesc>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A `@Param('id')` scalar or a bare `@Param()` covering all path params. */
|
|
166
|
+
function paramContribution(slot: ParamSlot, pathParams: string[]): SlotContribution {
|
|
167
|
+
if (slot.data) {
|
|
168
|
+
return {
|
|
169
|
+
binding: { kind: 'value', field: slot.data, source: 'path', metatype: slot.designType, pipes: slot.pipes },
|
|
170
|
+
fields: { [slot.data]: { type: 'string', required: true } }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const fields: Record<string, FieldDesc> = {}
|
|
174
|
+
for (const p of pathParams) { fields[p] = { type: 'string', required: true } }
|
|
175
|
+
return { binding: { kind: 'params', fields: pathParams }, fields }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A `@Query('x')`/`@Body('x')` scalar or a whole-DTO `@Query()`/`@Body()`. */
|
|
179
|
+
function bodyOrQueryContribution(slot: ParamSlot, source: 'query' | 'body', requiredScalar: boolean, designTypes: unknown[]): SlotContribution {
|
|
180
|
+
if (slot.data) {
|
|
181
|
+
return {
|
|
182
|
+
binding: { kind: 'value', field: slot.data, source, metatype: slot.designType, pipes: slot.pipes },
|
|
183
|
+
fields: { [slot.data]: mergeField(designTypeAt(designTypes, slot.index), { required: requiredScalar }) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const dtoFields = reflectDtoFields(slot.designType)
|
|
187
|
+
return {
|
|
188
|
+
binding: { kind: 'object', source, fields: Object.keys(dtoFields), metatype: slot.designType, pipes: slot.pipes },
|
|
189
|
+
fields: dtoFields
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Map one parameter slot to its input-field contribution and re-bind instruction. */
|
|
194
|
+
function contributeSlot(slot: ParamSlot, pathParams: string[], designTypes: unknown[]): SlotContribution {
|
|
195
|
+
switch (slot.paramtype) {
|
|
196
|
+
case PARAMTYPE.PARAM: return paramContribution(slot, pathParams)
|
|
197
|
+
case PARAMTYPE.QUERY: return bodyOrQueryContribution(slot, 'query', false, designTypes)
|
|
198
|
+
case PARAMTYPE.BODY: return bodyOrQueryContribution(slot, 'body', true, designTypes)
|
|
199
|
+
default: return { binding: specialBinding(slot.paramtype, slot.data) ?? { kind: 'missing' }, fields: {} }
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/lib/metadata.ts
CHANGED
|
@@ -1,58 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type z from 'zod/v4'
|
|
1
|
+
import type { z } from 'zod/v4'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
export const
|
|
3
|
+
/** Reflect-metadata key carrying `@Mcp` options on a controller method. */
|
|
4
|
+
export const MCP_METADATA = '__silkweave_mcp__'
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Options for the `@Mcp()` method decorator. Every field is optional - an empty
|
|
8
|
+
* `@Mcp()` exposes the decorated controller route as an MCP tool with its name,
|
|
9
|
+
* description, and input schema fully reflected from the method's route
|
|
10
|
+
* (`@Get`/`@Post`/...), parameter decorators (`@Param`/`@Query`/`@Body`), and
|
|
11
|
+
* any `@nestjs/swagger` (`@ApiOperation`/`@ApiParam`/`@ApiProperty`) or
|
|
12
|
+
* `class-validator` metadata it carries.
|
|
13
|
+
*/
|
|
14
|
+
export interface McpMetadata {
|
|
15
|
+
/**
|
|
16
|
+
* MCP tool name override. When unset it is derived from the controller class
|
|
17
|
+
* and method name (e.g. `ChannelsController.findOne` → `ChannelsFindOne`).
|
|
18
|
+
*/
|
|
11
19
|
name?: string
|
|
12
|
-
/** Human-readable description. Becomes the MCP tool description and REST OpenAPI summary. */
|
|
13
|
-
description: string
|
|
14
|
-
/** Zod object schema for the action's input. */
|
|
15
|
-
input: z.ZodType<I> & { shape: Record<string, z.ZodTypeAny> }
|
|
16
|
-
/** Optional Zod object schema for the action's output (used by tRPC type inference). */
|
|
17
|
-
output?: z.ZodType<O> & { shape: Record<string, z.ZodTypeAny> }
|
|
18
20
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Mirrors `Action.chunk` in @silkweave/core; typegen/trpc use it to expose
|
|
22
|
-
* the action as a tRPC subscription.
|
|
21
|
+
* Tool description override. When unset it falls back to the method's
|
|
22
|
+
* `@ApiOperation({ summary | description })`, then a generated default.
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
toolResult?: Action<I, O>['toolResult']
|
|
39
|
-
/** CLI positional-argument keys (unused for HTTP-only adapters, kept for parity with `createAction`). */
|
|
40
|
-
args?: (keyof I)[]
|
|
41
|
-
/** Custom MCP tool name override. If unset, derived from action name as PascalCase. */
|
|
42
|
-
mcpToolName?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ActionsClassMetadata {
|
|
46
|
-
/** Prefix joined to the method-level action name with a dot. e.g. `Actions('users')` + method `list` → `users.list`. */
|
|
47
|
-
prefix?: string
|
|
48
|
-
/** Class-level transports allowlist. Method-level `transports` overrides; otherwise inherits this. */
|
|
49
|
-
transports?: Transport[]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ResultToolResult<O extends object = object> {
|
|
53
|
-
toolResult?: Action<object, O>['toolResult']
|
|
24
|
+
description?: string
|
|
25
|
+
/**
|
|
26
|
+
* Zod raw-shape override merged over the reflected input fields (override
|
|
27
|
+
* wins per field). The escape hatch for shapes reflection can't express
|
|
28
|
+
* losslessly - discriminated unions, custom validators, `@Transform`, etc.
|
|
29
|
+
*/
|
|
30
|
+
input?: Record<string, z.ZodType>
|
|
31
|
+
/**
|
|
32
|
+
* Whether to apply the controller method's parameter-bound pipes
|
|
33
|
+
* (`@Param('id', ParseIntPipe)`) when re-binding the call. Default `'apply'`.
|
|
34
|
+
* Global/`ValidationPipe`, interceptors, and exception filters never run -
|
|
35
|
+
* the method is invoked directly, not through Nest's HTTP request pipeline.
|
|
36
|
+
*/
|
|
37
|
+
pipes?: 'apply' | 'skip'
|
|
54
38
|
}
|
|
55
|
-
|
|
56
|
-
export type AnyActionMetadata = ActionMetadata<object, object> & ResultToolResult<object>
|
|
57
|
-
|
|
58
|
-
export const ACTION_RESPONSE_KEY = '__silkweave_response__'
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
|
2
|
+
import { PARAMTYPE } from './reflect/params.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Instruction for reconstructing one positional handler argument from the flat
|
|
6
|
+
* MCP tool input. Built at discovery time, consumed by {@link invokeRebound}.
|
|
7
|
+
*/
|
|
8
|
+
export type Binding =
|
|
9
|
+
| { kind: 'value'; field: string; source: 'path' | 'query' | 'body'; metatype?: unknown; pipes?: unknown[] }
|
|
10
|
+
| { kind: 'object'; source: 'query' | 'body'; fields: string[]; metatype?: unknown; pipes?: unknown[] }
|
|
11
|
+
| { kind: 'params'; fields: string[] }
|
|
12
|
+
| { kind: 'request' }
|
|
13
|
+
| { kind: 'response' }
|
|
14
|
+
| { kind: 'headers'; data?: string }
|
|
15
|
+
| { kind: 'ip' }
|
|
16
|
+
| { kind: 'host'; data?: string }
|
|
17
|
+
| { kind: 'missing' }
|
|
18
|
+
|
|
19
|
+
interface RequestLike {
|
|
20
|
+
headers?: Record<string, unknown>
|
|
21
|
+
ip?: unknown
|
|
22
|
+
hosts?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function newInstance(P: any): any | null {
|
|
26
|
+
try { return new P() } catch { return null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function applyPipes(value: unknown, pipes: unknown[] | undefined, metatype: unknown, type: 'param' | 'query' | 'body', data: string | undefined): Promise<unknown> {
|
|
30
|
+
if (!pipes || pipes.length === 0) { return value }
|
|
31
|
+
let current = value
|
|
32
|
+
for (const p of pipes) {
|
|
33
|
+
const pipe = typeof p === 'function' ? newInstance(p) : p
|
|
34
|
+
if (pipe && typeof (pipe).transform === 'function') {
|
|
35
|
+
current = await (pipe).transform(current, { type, metatype, data })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return current
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pick a subset of keys (skipping `undefined`) into a fresh object. */
|
|
42
|
+
function pickFields(input: Record<string, unknown>, fields: string[]): Record<string, unknown> {
|
|
43
|
+
const obj: Record<string, unknown> = {}
|
|
44
|
+
for (const f of fields) {
|
|
45
|
+
if (input[f] !== undefined) { obj[f] = input[f] }
|
|
46
|
+
}
|
|
47
|
+
return obj
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve a single positional argument from one binding. */
|
|
51
|
+
async function resolveArg(
|
|
52
|
+
b: Binding,
|
|
53
|
+
input: Record<string, unknown>,
|
|
54
|
+
request: RequestLike | undefined,
|
|
55
|
+
applyParamPipes: boolean
|
|
56
|
+
): Promise<unknown> {
|
|
57
|
+
switch (b.kind) {
|
|
58
|
+
case 'value': {
|
|
59
|
+
const raw = input[b.field]
|
|
60
|
+
const type = b.source === 'path' ? 'param' : b.source
|
|
61
|
+
return applyParamPipes ? applyPipes(raw, b.pipes, b.metatype, type, b.field) : raw
|
|
62
|
+
}
|
|
63
|
+
case 'object': {
|
|
64
|
+
const obj = pickFields(input, b.fields)
|
|
65
|
+
return applyParamPipes ? applyPipes(obj, b.pipes, b.metatype, b.source, undefined) : obj
|
|
66
|
+
}
|
|
67
|
+
case 'params':
|
|
68
|
+
return pickFields(input, b.fields)
|
|
69
|
+
case 'headers':
|
|
70
|
+
return b.data ? request?.headers?.[b.data.toLowerCase()] : (request?.headers ?? {})
|
|
71
|
+
case 'request':
|
|
72
|
+
return request ?? { headers: {} }
|
|
73
|
+
case 'ip':
|
|
74
|
+
return request?.ip
|
|
75
|
+
case 'host':
|
|
76
|
+
return b.data ? request?.hosts?.[b.data] : request?.hosts
|
|
77
|
+
default:
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reconstruct the controller method's positional arguments from the validated
|
|
84
|
+
* tool `input` (and the request stand-in for `@Req`/`@Headers`/...), then call
|
|
85
|
+
* it. `applyParamPipes` controls whether parameter-bound pipes run.
|
|
86
|
+
*/
|
|
87
|
+
export async function invokeRebound(
|
|
88
|
+
method: (...args: any[]) => any,
|
|
89
|
+
instance: object,
|
|
90
|
+
input: Record<string, unknown>,
|
|
91
|
+
bindings: Binding[],
|
|
92
|
+
request: RequestLike | undefined,
|
|
93
|
+
applyParamPipes: boolean
|
|
94
|
+
): Promise<unknown> {
|
|
95
|
+
const args: unknown[] = []
|
|
96
|
+
for (let i = 0; i < bindings.length; i += 1) {
|
|
97
|
+
args[i] = await resolveArg(bindings[i], input, request, applyParamPipes)
|
|
98
|
+
}
|
|
99
|
+
return await method.apply(instance, args)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Map a non-input parameter slot (`@Req`/`@Headers`/`@Ip`/...) to its runtime binding. */
|
|
103
|
+
export function specialBinding(paramtype: number, data: string | undefined): Binding | null {
|
|
104
|
+
switch (paramtype) {
|
|
105
|
+
case PARAMTYPE.REQUEST: return { kind: 'request' }
|
|
106
|
+
case PARAMTYPE.RESPONSE:
|
|
107
|
+
case PARAMTYPE.NEXT: return { kind: 'response' }
|
|
108
|
+
case PARAMTYPE.HEADERS: return { kind: 'headers', data }
|
|
109
|
+
case PARAMTYPE.IP: return { kind: 'ip' }
|
|
110
|
+
case PARAMTYPE.HOST: return { kind: 'host', data }
|
|
111
|
+
case PARAMTYPE.SESSION:
|
|
112
|
+
case PARAMTYPE.FILE:
|
|
113
|
+
case PARAMTYPE.FILES:
|
|
114
|
+
case PARAMTYPE.RAW_BODY: return { kind: 'missing' }
|
|
115
|
+
default: return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
let cached: any | null | undefined
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lazily resolve the optional `class-validator` peer; `null` when not installed.
|
|
9
|
+
* Resolution is attempted both from this package and from the app's working
|
|
10
|
+
* directory - the latter is what finds it in a pnpm install where the optional
|
|
11
|
+
* peer lives in the consumer app rather than alongside `@silkweave/nestjs`. A
|
|
12
|
+
* pnpm-deduped install shares one physical copy, so the metadata singleton the
|
|
13
|
+
* app's decorators wrote to is the same one we read.
|
|
14
|
+
*/
|
|
15
|
+
function loadClassValidator(): any | null {
|
|
16
|
+
if (cached !== undefined) { return cached }
|
|
17
|
+
for (const base of [import.meta.url, join(process.cwd(), 'noop.js')]) {
|
|
18
|
+
try {
|
|
19
|
+
cached = createRequire(base)('class-validator')
|
|
20
|
+
return cached
|
|
21
|
+
} catch { /* try the next resolution base */ }
|
|
22
|
+
}
|
|
23
|
+
cached = null
|
|
24
|
+
return cached
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ValidationMeta {
|
|
28
|
+
/** `ValidationTypes` discriminator (e.g. `customValidation`, `conditionalValidation`). */
|
|
29
|
+
type?: string
|
|
30
|
+
/** Validator name - the actionable identity for built-ins (`isString`, `minLength`, ...). */
|
|
31
|
+
name?: string
|
|
32
|
+
constraints?: unknown[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read `class-validator` metadata for a DTO class, grouped by property name.
|
|
37
|
+
* Returns an empty map when `class-validator` is not installed or the class
|
|
38
|
+
* carries no validation decorators - so callers degrade gracefully to swagger /
|
|
39
|
+
* `design:type` reflection.
|
|
40
|
+
*/
|
|
41
|
+
export function classValidatorMetas(dtoType: any): Record<string, ValidationMeta[]> {
|
|
42
|
+
const cv = loadClassValidator()
|
|
43
|
+
if (!cv?.getMetadataStorage) { return {} }
|
|
44
|
+
let metas: Array<{ propertyName?: string; type?: string; name?: string; constraints?: unknown[] }>
|
|
45
|
+
try {
|
|
46
|
+
metas = cv.getMetadataStorage().getTargetValidationMetadatas(dtoType, null, false, false)
|
|
47
|
+
} catch {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
const out: Record<string, ValidationMeta[]> = {}
|
|
51
|
+
for (const m of metas) {
|
|
52
|
+
if (!m.propertyName) { continue }
|
|
53
|
+
(out[m.propertyName] ??= []).push({ type: m.type, name: m.name, constraints: m.constraints })
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
|
2
|
+
import { type FieldDesc, mergeField, openapiSchemaToField } from './schema.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A minimal view of an OpenAPI document - the subset we read. Matches the shape
|
|
6
|
+
* `SwaggerModule.createDocument()` returns, but typed loosely so callers can
|
|
7
|
+
* pass any compatible object without a hard `@nestjs/swagger` dependency.
|
|
8
|
+
*/
|
|
9
|
+
export interface OpenApiDocument {
|
|
10
|
+
paths?: Record<string, Record<string, any>>
|
|
11
|
+
components?: { schemas?: Record<string, any> }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OpenApiLookup {
|
|
15
|
+
doc: OpenApiDocument
|
|
16
|
+
/** `${METHOD} ${path}` → operation object. */
|
|
17
|
+
operations: Map<string, any>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Index a document's operations by `${METHOD} ${path}` for fast matching. */
|
|
21
|
+
export function buildOpenApiLookup(doc: OpenApiDocument): OpenApiLookup {
|
|
22
|
+
const operations = new Map<string, any>()
|
|
23
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
24
|
+
for (const [verb, op] of Object.entries(item ?? {})) {
|
|
25
|
+
operations.set(`${verb.toUpperCase()} ${path}`, op)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { doc, operations }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Locate the operation for a route, tolerating a global path prefix on the document side. */
|
|
32
|
+
function findOperation(lookup: OpenApiLookup, method: string, openapiPath: string): any | undefined {
|
|
33
|
+
const exact = lookup.operations.get(`${method} ${openapiPath}`)
|
|
34
|
+
if (exact) { return exact }
|
|
35
|
+
// Fall back to a suffix match so a `setGlobalPrefix('api')` document still resolves.
|
|
36
|
+
for (const [key, op] of lookup.operations) {
|
|
37
|
+
const [verb, path] = key.split(' ')
|
|
38
|
+
if (verb === method && (path.endsWith(openapiPath) || openapiPath.endsWith(path))) { return op }
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveRef(doc: OpenApiDocument, schema: any): any {
|
|
44
|
+
let current = schema
|
|
45
|
+
let guard = 0
|
|
46
|
+
while (current && typeof current === 'object' && typeof current['$ref'] === 'string' && guard < 10) {
|
|
47
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(current['$ref'])
|
|
48
|
+
if (!match) { break }
|
|
49
|
+
current = doc.components?.schemas?.[match[1]]
|
|
50
|
+
guard += 1
|
|
51
|
+
}
|
|
52
|
+
return current
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the per-field descriptors for a route from an ingested OpenAPI
|
|
57
|
+
* document. Merges `parameters` (path/query/header) and the JSON request-body
|
|
58
|
+
* schema's properties into a single field map. Returns `{}` when the operation
|
|
59
|
+
* isn't found - callers fall back to decorator reflection.
|
|
60
|
+
*/
|
|
61
|
+
export function openApiFields(lookup: OpenApiLookup, method: string, openapiPath: string): Record<string, FieldDesc> {
|
|
62
|
+
const op = findOperation(lookup, method, openapiPath)
|
|
63
|
+
if (!op) { return {} }
|
|
64
|
+
const out: Record<string, FieldDesc> = {}
|
|
65
|
+
|
|
66
|
+
for (const param of (op['parameters'] as Array<Record<string, any>> | undefined) ?? []) {
|
|
67
|
+
const name = typeof param['name'] === 'string' ? param['name'] : undefined
|
|
68
|
+
if (!name) { continue }
|
|
69
|
+
const schema = param['schema'] ? resolveRef(lookup.doc, param['schema']) : undefined
|
|
70
|
+
let field = schema ? openapiSchemaToField(schema) : {}
|
|
71
|
+
if (param['description']) { field = mergeField(field, { description: param['description'] }) }
|
|
72
|
+
if (typeof param['required'] === 'boolean') { field = mergeField(field, { required: param['required'] }) }
|
|
73
|
+
out[name] = field
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const bodySchema = resolveRef(lookup.doc, op['requestBody']?.['content']?.['application/json']?.['schema'])
|
|
77
|
+
if (bodySchema && typeof bodySchema === 'object' && bodySchema['properties']) {
|
|
78
|
+
const required = new Set<string>(Array.isArray(bodySchema['required']) ? bodySchema['required'] : [])
|
|
79
|
+
for (const [name, propSchema] of Object.entries(bodySchema['properties'] as Record<string, any>)) {
|
|
80
|
+
const field = openapiSchemaToField(resolveRef(lookup.doc, propSchema))
|
|
81
|
+
field.required = required.has(name)
|
|
82
|
+
out[name] = field
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out
|
|
87
|
+
}
|