@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/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @silkweave/nestjs
|
|
2
2
|
|
|
3
|
-
NestJS adapter for [Silkweave](https://github.com/silkweave/silkweave).
|
|
3
|
+
NestJS adapter for [Silkweave](https://github.com/silkweave/silkweave). Expose your **existing NestJS controllers** as MCP (Model Context Protocol) tools by adding a single `@Mcp()` decorator to a route handler. The tool's name, description, and input schema are **reflected** from the route, the `@Param`/`@Query`/`@Body` decorators, and any `@nestjs/swagger` / `class-validator` metadata the method already carries - nothing is re-declared. On a tool call the validated input is split back into the method's positional arguments and the handler is invoked directly, with `@UseGuards()` guards applied first.
|
|
4
|
+
|
|
5
|
+
It is **additive**: controllers keep serving HTTP exactly as before, and removing `@Mcp()` fully reverts a method.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,52 +10,59 @@ NestJS adapter for [Silkweave](https://github.com/silkweave/silkweave). Define a
|
|
|
8
10
|
pnpm add @silkweave/core @silkweave/nestjs @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
`@nestjs/swagger` and `class-validator` are **optional** peers - install whichever your DTOs already use; the reflector reads both and falls back to TypeScript `design:type` when neither is present.
|
|
14
|
+
|
|
15
|
+
> NestJS needs decorator-aware transforms with `emitDecoratorMetadata`. Use `@swc-node/register` (`node --import @swc-node/register/esm-register src/main.ts`) or the `nest start` CLI; plain `tsx` does not emit decorator metadata.
|
|
12
16
|
|
|
13
17
|
## Usage
|
|
14
18
|
|
|
15
19
|
```ts
|
|
16
|
-
// users.
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import
|
|
20
|
+
// users.controller.ts
|
|
21
|
+
import { Body, Controller, Get, NotFoundException, Param, Post, Query, UseGuards } from '@nestjs/common'
|
|
22
|
+
import { ApiOperation, ApiParam, ApiProperty, ApiQuery } from '@nestjs/swagger'
|
|
23
|
+
import { Mcp } from '@silkweave/nestjs'
|
|
24
|
+
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'
|
|
21
25
|
import { AdminGuard } from './admin.guard.js'
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
class BanUserDto {
|
|
28
|
+
@ApiProperty({ description: 'Reason for the ban' })
|
|
29
|
+
@IsString() @MinLength(3)
|
|
30
|
+
reason!: string
|
|
26
31
|
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
|
|
32
|
+
@ApiProperty({ description: 'Whether the ban is permanent', required: false })
|
|
33
|
+
@IsOptional() @IsBoolean()
|
|
34
|
+
permanent?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Controller('users')
|
|
38
|
+
export class UsersController {
|
|
30
39
|
constructor(private readonly db: DbService) {}
|
|
31
40
|
|
|
32
|
-
@
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return this.db.listUsers(input.activeOnly)
|
|
41
|
+
@Get()
|
|
42
|
+
@ApiOperation({ summary: 'List users' })
|
|
43
|
+
@ApiQuery({ name: 'activeOnly', required: false, type: Boolean, description: 'Only active users' })
|
|
44
|
+
@Mcp()
|
|
45
|
+
list(@Query('activeOnly') activeOnly?: boolean) {
|
|
46
|
+
return this.db.listUsers(activeOnly)
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
@
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
@Get(':id')
|
|
50
|
+
@ApiOperation({ summary: 'Get a single user by ID' })
|
|
51
|
+
@ApiParam({ name: 'id', description: 'User ID' })
|
|
52
|
+
@Mcp()
|
|
53
|
+
get(@Param('id') id: string) {
|
|
54
|
+
const user = this.db.getUser(id)
|
|
55
|
+
if (!user) { throw new NotFoundException('user not found') }
|
|
56
|
+
return user
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
@
|
|
51
|
-
@
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
})
|
|
55
|
-
ban(
|
|
56
|
-
return this.db.banUser(
|
|
59
|
+
@Post(':id/ban')
|
|
60
|
+
@ApiOperation({ summary: 'Ban a user' })
|
|
61
|
+
@ApiParam({ name: 'id', description: 'User ID' })
|
|
62
|
+
@UseGuards(AdminGuard) // runs on every transport, MCP included
|
|
63
|
+
@Mcp({ description: 'Ban a user (admin only).' })
|
|
64
|
+
ban(@Param('id') id: string, @Body() body: BanUserDto) {
|
|
65
|
+
return this.db.banUser(id, body.reason, body.permanent ?? false)
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
```
|
|
@@ -61,22 +70,19 @@ export class UserActions {
|
|
|
61
70
|
```ts
|
|
62
71
|
// app.module.ts
|
|
63
72
|
import { Module } from '@nestjs/common'
|
|
64
|
-
import { mcp,
|
|
73
|
+
import { mcp, SilkweaveModule } from '@silkweave/nestjs'
|
|
65
74
|
import { AdminGuard } from './admin.guard.js'
|
|
66
|
-
import {
|
|
75
|
+
import { UsersController } from './users.controller.js'
|
|
67
76
|
|
|
68
77
|
@Module({
|
|
69
78
|
imports: [
|
|
70
79
|
SilkweaveModule.forRoot({
|
|
71
80
|
silkweave: { name: 'my-app', description: 'My App', version: '1.0.0' },
|
|
72
|
-
adapters: [
|
|
73
|
-
rest({ basePath: '/api' }),
|
|
74
|
-
trpc({ basePath: '/trpc' }),
|
|
75
|
-
mcp({ basePath: '/mcp' })
|
|
76
|
-
]
|
|
81
|
+
adapters: [mcp({ basePath: '/mcp' })]
|
|
77
82
|
})
|
|
78
83
|
],
|
|
79
|
-
|
|
84
|
+
controllers: [UsersController],
|
|
85
|
+
providers: [AdminGuard]
|
|
80
86
|
})
|
|
81
87
|
export class AppModule {}
|
|
82
88
|
```
|
|
@@ -91,120 +97,64 @@ const app = await NestFactory.create(AppModule)
|
|
|
91
97
|
await app.listen(8080)
|
|
92
98
|
```
|
|
93
99
|
|
|
94
|
-
The `
|
|
100
|
+
The MCP endpoint at `/mcp` now exposes three tools - `UsersList`, `UsersGet`, `UsersBan` - whose input schemas are reflected from the controllers:
|
|
95
101
|
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
102
|
+
- `UsersGet` → `{ id: string }` (`id` required, described "User ID" from `@ApiParam`)
|
|
103
|
+
- `UsersList` → `{ activeOnly?: boolean }` (optional, typed/described from `@ApiQuery`)
|
|
104
|
+
- `UsersBan` → `{ id: string, reason: string (minLength 3), permanent?: boolean }` (flattened from the path param + `BanUserDto`, with `@ApiProperty` + `class-validator` merged)
|
|
99
105
|
|
|
100
|
-
`
|
|
106
|
+
`UsersBan` is guarded by `@UseGuards(AdminGuard)`; over MCP the guard reads the inbound tool-call headers (see [Guards & DI](#guards--di)).
|
|
101
107
|
|
|
102
|
-
##
|
|
108
|
+
## Decorator
|
|
103
109
|
|
|
104
|
-
### `@
|
|
110
|
+
### `@Mcp(options?)` (method decorator)
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|--------|------|---------|-------------|
|
|
108
|
-
| `name` | `string` | kebab-cased method name | Override the action name. Joined with the class prefix via `.` |
|
|
109
|
-
| `description` | `string` | *required* | Human-readable summary. Used as MCP tool description |
|
|
110
|
-
| `input` | `z.ZodObject` | *required* | Zod object schema for the action's input |
|
|
111
|
-
| `output` | `z.ZodObject` | - | Optional output schema (used by tRPC type inference) |
|
|
112
|
-
| `chunk` | `z.ZodType` | - | Schema for chunks yielded by a streaming (`async function*`) method. **Required** when the method is an async generator |
|
|
113
|
-
| `kind` | `'query' \| 'mutation'` | `'mutation'` | `'query'` → GET in REST, `.query()` in tRPC. `'mutation'` → POST / `.mutation()` |
|
|
114
|
-
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` | `POST` (or `GET` when `kind: 'query'`) | REST HTTP verb. Overrides the `kind`-derived default |
|
|
115
|
-
| `path` | `string` | action name with dots as slashes | REST route, optionally with `:param` placeholders (e.g. `'spaces/:spaceId/users'`). Each placeholder must be a key of `input` and is resolved from the URL path |
|
|
116
|
-
| `queryParams` | `(keyof input)[]` | - | Input fields read from the URL query string instead of the body (e.g. `['offset', 'limit']`). On a bodyless GET every non-path field is read from the query string automatically |
|
|
117
|
-
| `transports` | `('rest' \| 'trpc' \| 'mcp')[]` | all | Allowlist of transports that expose this action |
|
|
118
|
-
| `isEnabled` | `(ctx) => boolean` | - | Dynamic gate (AND-combined with `transports`) |
|
|
119
|
-
| `toolResult` | `(response, ctx) => CallToolResult` | - | Custom MCP `CallToolResult` formatter |
|
|
120
|
-
|
|
121
|
-
### `@Actions(prefix?)` (class decorator)
|
|
122
|
-
|
|
123
|
-
Groups a class's actions under a common prefix. Joined to method-level names with a dot.
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
@Actions('users')
|
|
127
|
-
class UserActions {
|
|
128
|
-
@Action({ ... }) list(...) {} // action name: 'users.list'
|
|
129
|
-
@Action({ name: 'top' }) ... {} // action name: 'users.top'
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
Accepts either a string (shorthand) or `{ prefix, transports }` for a class-wide transport default.
|
|
134
|
-
|
|
135
|
-
## Adapters
|
|
136
|
-
|
|
137
|
-
### `rest(options?)`
|
|
138
|
-
|
|
139
|
-
Maps actions to REST routes on the Nest HTTP server.
|
|
112
|
+
Exposes the decorated controller route as an MCP tool. Every option is optional.
|
|
140
113
|
|
|
141
114
|
| Option | Type | Default | Description |
|
|
142
115
|
|--------|------|---------|-------------|
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
- `users.list` (query) → `GET /api/users/list`
|
|
149
|
-
- `users.ban` (mutation) → `POST /api/users/ban`
|
|
150
|
-
|
|
151
|
-
The action's `method`, `path`, and `queryParams` fields override this (see the `@Action` options table above):
|
|
116
|
+
| `name` | `string` | `${ControllerBase}${MethodName}` (e.g. `UsersGet`) | MCP tool name override |
|
|
117
|
+
| `description` | `string` | `@ApiOperation` summary/description, else generated | Tool description |
|
|
118
|
+
| `input` | `Record<string, z.ZodType>` | - | Zod raw-shape override merged over the reflected fields (per-field). The escape hatch for shapes reflection can't express - discriminated unions, custom validators, `@Transform` |
|
|
119
|
+
| `pipes` | `'apply' \| 'skip'` | `'apply'` | Whether to run parameter-bound pipes (`@Param('id', ParseIntPipe)`) when re-binding |
|
|
152
120
|
|
|
153
|
-
|
|
154
|
-
- `queryParams: ['activeOnly']`, `kind: 'query'` → `GET /api/users/list?activeOnly=true`
|
|
121
|
+
## How reflection works
|
|
155
122
|
|
|
156
|
-
|
|
123
|
+
For each `@Mcp` method the adapter builds **one flat Zod input object** by merging, per field, in increasing precedence:
|
|
157
124
|
|
|
158
|
-
|
|
125
|
+
1. TypeScript `design:type` (the parameter/property constructor)
|
|
126
|
+
2. `class-validator` decorators (`@IsString`, `@MinLength`, `@IsOptional`, `@IsEnum`, ...) - **optional** peer
|
|
127
|
+
3. `@nestjs/swagger` decorators - `@ApiParam`/`@ApiQuery` for scalar path/query params, `@ApiProperty` for whole-DTO properties - **optional** peer
|
|
128
|
+
4. An ingested OpenAPI document, matched by HTTP verb + path (`SilkweaveModule.forRoot({ openapi })`)
|
|
129
|
+
5. `@Mcp({ input })` raw-shape override
|
|
159
130
|
|
|
160
|
-
|
|
131
|
+
Field sources are derived from the parameter decorators:
|
|
161
132
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
133
|
+
| Controller parameter | Tool input |
|
|
134
|
+
|----------------------|-----------|
|
|
135
|
+
| `@Param('id') id` | scalar field `id` |
|
|
136
|
+
| `@Param() params` | one field per `:param` in the route |
|
|
137
|
+
| `@Query('limit') limit` | scalar field `limit` |
|
|
138
|
+
| `@Query() dto: ListDto` | each property of `ListDto`, flattened to top level |
|
|
139
|
+
| `@Body('x') x` | scalar field `x` |
|
|
140
|
+
| `@Body() dto: CreateDto` | each property of `CreateDto`, flattened to top level |
|
|
141
|
+
| `@Req`/`@Res`/`@Headers`/`@Ip`/`@Session`/files | not exposed; bound at call time (headers/req from the MCP request stand-in, the rest `undefined`) |
|
|
171
142
|
|
|
172
|
-
|
|
173
|
-
|--------|------|---------|-------------|
|
|
174
|
-
| `basePath` | `string` | the `rest()` adapter's `basePath`, else `'/api'` | URL prefix the action routes are mounted on |
|
|
175
|
-
| `tag` | `string` | `'Actions'` | OpenAPI tag the actions are grouped under |
|
|
176
|
-
| `includeDisabled` | `boolean` | `false` | Include actions gated off the REST transport (via `transports` / `isEnabled`) |
|
|
143
|
+
On a tool call the validated input is split back into the handler's positional arguments per the same parameter map, parameter-bound pipes run (unless `pipes: 'skip'`), and the method is invoked directly.
|
|
177
144
|
|
|
178
|
-
|
|
145
|
+
### Optional OpenAPI ingestion
|
|
179
146
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
Mounts a tRPC HTTP handler built from `@silkweave/trpc`'s `buildRouter`.
|
|
183
|
-
|
|
184
|
-
| Option | Type | Default | Description |
|
|
185
|
-
|--------|------|---------|-------------|
|
|
186
|
-
| `basePath` | `string` | `'/trpc'` | URL prefix the tRPC handler listens on |
|
|
187
|
-
| `auth` | `AuthConfig` | - | `@silkweave/auth` bearer-token config |
|
|
188
|
-
|
|
189
|
-
Action names with dots (e.g. `users.list`) collapse to camelCase procedure keys (`usersList`) - flat router in v1.
|
|
190
|
-
|
|
191
|
-
**Streaming.** An `@Action` method declared as an `async function*` (with a `chunk` schema) is registered as a tRPC **subscription** that streams each yielded chunk over SSE - exactly like a standalone `createAction({ chunk, run: async function*(){…} })`. `@UseGuards()` guards still run before the first chunk. This is what `useChat` + `@silkweave/ai`'s `silkweaveTransport()` consume.
|
|
147
|
+
Pass a pre-built OpenAPI document (e.g. a committed `openapi.json`, or one built in a two-phase bootstrap) to use it as the authoritative schema source. It is matched to each `@Mcp` method by verb + path and overrides decorator reflection for the fields it covers; unmatched operations/fields fall back to reflection.
|
|
192
148
|
|
|
193
149
|
```ts
|
|
194
|
-
|
|
195
|
-
description: '
|
|
196
|
-
|
|
197
|
-
|
|
150
|
+
SilkweaveModule.forRoot({
|
|
151
|
+
silkweave: { name: 'my-app', description: 'My App', version: '1.0.0' },
|
|
152
|
+
adapters: [mcp()],
|
|
153
|
+
openapi: openApiDocument
|
|
198
154
|
})
|
|
199
|
-
async *countdown(input: { from: number }) {
|
|
200
|
-
for (let n = input.from; n >= 0; n -= 1) {
|
|
201
|
-
await new Promise((r) => setTimeout(r, 200))
|
|
202
|
-
yield { n }
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
155
|
```
|
|
206
156
|
|
|
207
|
-
|
|
157
|
+
## Adapter
|
|
208
158
|
|
|
209
159
|
### `mcp(options?)`
|
|
210
160
|
|
|
@@ -220,46 +170,29 @@ Mounts the MCP Streamable HTTP transport directly at `basePath`, with sideload (
|
|
|
220
170
|
|
|
221
171
|
## Guards & DI
|
|
222
172
|
|
|
223
|
-
Native NestJS `@UseGuards()`
|
|
173
|
+
Native NestJS `@UseGuards()` on `@Mcp` methods run before the handler is invoked. Guards receive an `ExecutionContext` with the request in `switchToHttp().getRequest()`, and the `Reflector` is wired up so guards can read custom metadata.
|
|
224
174
|
|
|
225
|
-
**Headers over MCP.**
|
|
175
|
+
**Headers over MCP.** The inbound tool-call request is surfaced as a stand-in `{ headers, url, params, query }` object built from the MCP SDK's `extra.requestInfo`, so a header-based guard - e.g. one reading `getRequest().headers['x-api-key']` - works unchanged. `ExecutionContext.getType()` is `'http'` whenever a request is available (and `'rpc'` for transports with none, e.g. MCP stdio). Caveats:
|
|
226
176
|
|
|
227
|
-
- Only **headers** (and the request `url`) cross the MCP boundary
|
|
177
|
+
- Only **headers** (and the request `url`) cross the MCP boundary - `getRequest().params`/`.query` are empty objects.
|
|
228
178
|
- A guard that denies (returns `false` or throws) produces a clean MCP tool error (`ForbiddenException`), not an HTTP 500.
|
|
229
|
-
- For OAuth 2.1 / bearer-token MCP auth, prefer `mcp({ auth })` and read the resolved identity from the silkweave context (`ctx.get('auth')`)
|
|
179
|
+
- For OAuth 2.1 / bearer-token MCP auth, prefer `mcp({ auth })` and read the resolved identity from the silkweave context (`ctx.get('auth')`); the header stand-in is for custom request-reading guards.
|
|
230
180
|
|
|
231
|
-
|
|
181
|
+
Controllers are normal Nest providers - inject services via the constructor as usual.
|
|
232
182
|
|
|
233
|
-
|
|
234
|
-
@Injectable()
|
|
235
|
-
class MyActions {
|
|
236
|
-
constructor(
|
|
237
|
-
private readonly db: DbService,
|
|
238
|
-
private readonly cache: CacheService
|
|
239
|
-
) {}
|
|
240
|
-
|
|
241
|
-
@UseGuards(AuthGuard, RateLimitGuard)
|
|
242
|
-
@Action({ description: '...', input: z.object({...}) })
|
|
243
|
-
async myAction(input, ctx) {
|
|
244
|
-
return this.db.query(...)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
```
|
|
183
|
+
## What does *not* run
|
|
248
184
|
|
|
249
|
-
|
|
185
|
+
Because the handler is invoked directly (not through Nest's HTTP request pipeline), the following do **not** apply on a tool call - only `@UseGuards()` and parameter-bound pipes do:
|
|
250
186
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
| `users.list` | `/users/list` | `usersList` | `UsersList` |
|
|
255
|
-
| `posts.get-by-id` | `/posts/get-by-id` | `postsGetById` | `PostsGetById` |
|
|
187
|
+
- Globally-registered `ValidationPipe` / interceptors / exception filters (MCP input is instead validated against the reflected Zod schema).
|
|
188
|
+
- DTO class instantiation - whole-DTO `@Body()`/`@Query()` arguments arrive as plain objects, so `@Transform` and DTO methods do not fire.
|
|
189
|
+
- Discriminated-union / `@ValidateIf` XOR constraints can't be expressed as hard MCP schema constraints - document them in the tool `description` or supply `@Mcp({ input })`.
|
|
256
190
|
|
|
257
191
|
## Notes
|
|
258
192
|
|
|
259
|
-
- **
|
|
260
|
-
- **Express only by default.** `@nestjs/platform-fastify` requires `@fastify/express` registered upfront
|
|
261
|
-
-
|
|
262
|
-
- **`reflect-metadata`** must be imported once at the top of your entry file (typical Nest requirement).
|
|
193
|
+
- **Opt-in.** Only methods carrying `@Mcp()` are exposed - controllers are never auto-published.
|
|
194
|
+
- **Express only by default.** `@nestjs/platform-fastify` requires `@fastify/express` registered upfront.
|
|
195
|
+
- **`reflect-metadata`** must be imported once at the top of your entry file.
|
|
263
196
|
|
|
264
197
|
## License
|
|
265
198
|
|