@silkweave/nestjs 1.12.0 → 2.2.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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @silkweave/nestjs
2
2
 
3
- NestJS adapter for [Silkweave](https://github.com/silkweave/silkweave). Define actions as method decorators on Nest providers, then expose them simultaneously over REST, tRPC, and MCP Streamable HTTP - all mounted on the running Nest HTTP server so Nest middleware, guards, and lifecycle hooks stay coherent.
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
- > NestJS dev workflows need decorator-aware transforms. Recommend `@swc-node/register` (`node --import @swc-node/register/esm-register src/main.ts`) or the standard `nest start` CLI; plain `tsx` doesn't emit decorator metadata.
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.actions.ts
17
- import { Injectable, UseGuards } from '@nestjs/common'
18
- import { type SilkweaveContext } from '@silkweave/core'
19
- import { Action, Actions } from '@silkweave/nestjs'
20
- import z from 'zod/v4'
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
- const ListInput = z.object({ activeOnly: z.coerce.boolean().optional() })
24
- const GetInput = z.object({ id: z.string() })
25
- const BanInput = z.object({ id: z.string(), reason: z.string() })
27
+ class BanUserDto {
28
+ @ApiProperty({ description: 'Reason for the ban' })
29
+ @IsString() @MinLength(3)
30
+ reason!: string
26
31
 
27
- @Injectable()
28
- @Actions('users')
29
- export class UserActions {
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
- @Action({
33
- description: 'List users',
34
- input: ListInput,
35
- kind: 'query'
36
- })
37
- list(input: z.infer<typeof ListInput>, _ctx: SilkweaveContext) {
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
- @Action({
42
- description: 'Get a single user by ID',
43
- input: GetInput,
44
- kind: 'query'
45
- })
46
- get(input: z.infer<typeof GetInput>) {
47
- return this.db.getUser(input.id)
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
- @UseGuards(AdminGuard) // guard reads the request header on every transport, MCP included
51
- @Action({
52
- description: 'Ban a user',
53
- input: BanInput
54
- })
55
- ban(input: z.infer<typeof BanInput>) {
56
- return this.db.banUser(input.id, input.reason)
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, rest, SilkweaveModule, trpc } from '@silkweave/nestjs'
73
+ import { mcp, SilkweaveModule } from '@silkweave/nestjs'
65
74
  import { AdminGuard } from './admin.guard.js'
66
- import { UserActions } from './users.actions.js'
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
- providers: [AdminGuard, UserActions]
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 `users.list` and `users.get` actions are now reachable via:
95
-
96
- - **REST:** `GET /api/users/list?activeOnly=true` (query param) and `GET /api/users/1` (path param, via `path: 'users/:id'`)
97
- - **tRPC:** `client.usersList.query({ activeOnly: true })` and `client.usersGet.query({ id: '1' })`
98
- - **MCP:** tools `UsersList` and `UsersGet`
99
-
100
- `users.ban` is guarded by `@UseGuards(AdminGuard)` on **every** transport - REST, tRPC, **and** MCP. The guard reads its credential from the request header (`switchToHttp().getRequest().headers`); over MCP the inbound tool-call headers are surfaced the same way (see [Guards & DI](#guards--di)).
101
-
102
- ## Decorators
103
-
104
- ### `@Action(options)` (method decorator)
105
-
106
- | Option | Type | Default | Description |
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.
100
+ The MCP endpoint at `/mcp` now exposes three tools - `UsersList`, `UsersGet`, `UsersBan` - whose input schemas are reflected from the controllers:
124
101
 
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
- ```
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)
132
105
 
133
- Accepts either a string (shorthand) or `{ prefix, transports }` for a class-wide transport default.
106
+ `UsersBan` is guarded by `@UseGuards(AdminGuard)`; over MCP the guard reads the inbound tool-call headers (see [Guards & DI](#guards--di)).
134
107
 
135
- ## Adapters
108
+ ## Decorator
136
109
 
137
- ### `rest(options?)`
110
+ ### `@Mcp(options?)` (method decorator)
138
111
 
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
- | `basePath` | `string` | `'/api'` | URL prefix joined to each action's path |
144
- | `auth` | `AuthConfig` | - | `@silkweave/auth` bearer-token config |
145
-
146
- By default routes follow `{basePath}/{action-name-with-slashes}` where dots in action names become slashes, and the verb comes from `kind`:
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):
152
-
153
- - `path: 'users/:id'`, `kind: 'query'` → `GET /api/users/:id` (`id` from the path)
154
- - `queryParams: ['activeOnly']`, `kind: 'query'` → `GET /api/users/list?activeOnly=true`
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 |
155
120
 
156
- Each action is registered as an individual route on Nest's HTTP adapter (not a sub-app). Input is merged from the request body, `queryParams` query-string fields, and `:param` path placeholders (path/query strings are coerced to the schema's primitive), then validated against the action's Zod schema; validation failures return HTTP 400 with the Zod issues.
121
+ ## How reflection works
157
122
 
158
- #### Swagger / OpenAPI
123
+ For each `@Mcp` method the adapter builds **one flat Zod input object** by merging, per field, in increasing precedence:
159
124
 
160
- `@nestjs/swagger` builds its document by scanning **controllers**, but Silkweave registers action routes directly on the HTTP adapter - so the scanner never sees them. `addSilkweaveActions(app, document, options?)` closes the gap: it discovers the actions through the same `ActionDiscovery` provider the `rest()` adapter uses, builds OpenAPI paths with the same routing logic, and merges them into the document. The result stays in sync with the live routes without any dynamic controllers. Call it between `createDocument()` and `setup()`:
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
161
130
 
162
- ```ts
163
- import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
164
- import { addSilkweaveActions } from '@silkweave/nestjs'
165
-
166
- const config = new DocumentBuilder().setTitle('My API').setVersion('1.0.0').build()
167
- const document = SwaggerModule.createDocument(app, config) // your controllers
168
- addSilkweaveActions(app, document) // + silkweave actions
169
- SwaggerModule.setup('api/docs', app, document)
170
- ```
131
+ Field sources are derived from the parameter decorators:
171
132
 
172
- | Option | Type | Default | Description |
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`) |
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`) |
177
142
 
178
- `@nestjs/swagger` is an **optional** peer dependency - install it alongside `@silkweave/nestjs` to use this helper. Path params, `queryParams`, request bodies, and `output`/`chunk` response schemas are all derived from the action's Zod schemas.
179
-
180
- ### `trpc(options?)`
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 |
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.
188
144
 
189
- Action names with dots (e.g. `users.list`) collapse to camelCase procedure keys (`usersList`) - flat router in v1.
145
+ ### Optional OpenAPI ingestion
190
146
 
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
- @Action({
195
- description: 'Stream a countdown',
196
- input: z.object({ from: z.number().int() }),
197
- chunk: z.object({ n: z.number().int() })
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
- Declaring an async-generator method without a `chunk` schema throws at discovery time - the schema is required for typegen/tRPC to expose the subscription.
157
+ ## Adapter
208
158
 
209
159
  ### `mcp(options?)`
210
160
 
@@ -220,46 +170,41 @@ Mounts the MCP Streamable HTTP transport directly at `basePath`, with sideload (
220
170
 
221
171
  ## Guards & DI
222
172
 
223
- Native NestJS `@UseGuards()` and `@UseInterceptors()` on `@Action` methods run for **every** transport - REST, tRPC, and MCP. Guards receive an `ExecutionContext` with the request in `switchToHttp().getRequest()`. The `Reflector` is also wired up so guards can read custom metadata.
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.** REST and tRPC pass the raw HTTP request to the guard. For MCP (Streamable HTTP), 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:
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. There are no path `params` or `query` on an MCP tool call, so `getRequest().params` / `.query` are empty objects - guards relying on them degrade to "deny" rather than crash.
177
+ - **Headers**, the request `url`, and **URL path params** cross the MCP boundary. `getRequest().params` is populated from the route's reflected `@Param` fields (as raw strings, like Express), so a path-scoped guard - e.g. one reading `getRequest().params['id']` to fence a key to one resource - works the same over MCP as over REST. `getRequest().query` is still empty (no query string over MCP).
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')`) inside the action; the header stand-in is for custom request-reading guards.
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
- Action methods are normal Nest provider methods - inject services via the constructor as usual.
181
+ **Global guards (opt-in).** App-global guards - registered via `app.useGlobalGuards(new X())` or `{ provide: APP_GUARD, useClass }` - do **not** run on tool calls by default. Opt them in by class with `globalGuards`:
232
182
 
233
183
  ```ts
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
- }
184
+ SilkweaveModule.forRoot({
185
+ silkweave: { name: 'app', description: 'My App', version: '1.0.0' },
186
+ adapters: [mcp({ basePath: '/mcp' })],
187
+ globalGuards: [ApiKeyGuard] // runs before each method/class @UseGuards; throttler etc. deliberately excluded
188
+ })
247
189
  ```
248
190
 
249
- ## Action name transport path
191
+ The allow-list is explicit-by-class on purpose - a blanket "run every global" would also fire unrelated globals (e.g. a `ThrottlerGuard`, which assumes a writable response MCP doesn't provide). Listed guards run **before** the method/class `@UseGuards`, mirroring Nest's request pipeline. They see the same request stand-in as method guards: headers, `url`, and path `params` (populated from reflected `@Param` fields); only `query`/IP-derived logic won't apply over MCP.
192
+
193
+ Controllers are normal Nest providers - inject services via the constructor as usual.
194
+
195
+ ## What does *not* run
196
+
197
+ Because the handler is invoked directly (not through Nest's HTTP request pipeline), the following do **not** apply on a tool call - only `@UseGuards()` (plus any opted-in `globalGuards`) and parameter-bound pipes do:
250
198
 
251
- | Action name | REST path | tRPC procedure | MCP tool |
252
- |-------------|-----------|----------------|----------|
253
- | `hello` | `/hello` | `hello` | `Hello` |
254
- | `users.list` | `/users/list` | `usersList` | `UsersList` |
255
- | `posts.get-by-id` | `/posts/get-by-id` | `postsGetById` | `PostsGetById` |
199
+ - Globally-registered `ValidationPipe` / interceptors / exception filters (MCP input is instead validated against the reflected Zod schema).
200
+ - DTO class instantiation - whole-DTO `@Body()`/`@Query()` arguments arrive as plain objects, so `@Transform` and DTO methods do not fire.
201
+ - 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
202
 
257
203
  ## Notes
258
204
 
259
- - **Lifecycle:** adapters mount their route slots in `OnModuleInit` (before Nest's 404 catch-all is installed) and populate the handlers in `OnApplicationBootstrap` after `@Action` discovery.
260
- - **Express only by default.** `@nestjs/platform-fastify` requires `@fastify/express` registered upfront so Nest can serve Express-style middleware (used by all three adapters internally).
261
- - **Query coercion for REST queries:** request query strings are always strings; use `z.coerce.boolean()` / `z.coerce.number()` in your Zod schemas for queries.
262
- - **`reflect-metadata`** must be imported once at the top of your entry file (typical Nest requirement).
205
+ - **Opt-in.** Only methods carrying `@Mcp()` are exposed - controllers are never auto-published.
206
+ - **Express only by default.** `@nestjs/platform-fastify` requires `@fastify/express` registered upfront.
207
+ - **`reflect-metadata`** must be imported once at the top of your entry file.
263
208
 
264
209
  ## License
265
210