@lenne.tech/nest-server 11.32.0 → 11.32.2

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 (33) hide show
  1. package/.claude/rules/configurable-features.md +52 -1
  2. package/FRAMEWORK-API.md +2 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  4. package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -3
  5. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  6. package/dist/core/modules/ai/models/core-ai-mode.model.js.map +1 -1
  7. package/dist/core/modules/ai/models/core-ai-tool-policy.model.js.map +1 -1
  8. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.d.ts +6 -1
  9. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +66 -7
  10. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  11. package/dist/core/modules/ai/services/core-ai.service.d.ts +1 -0
  12. package/dist/core/modules/ai/services/core-ai.service.js +13 -10
  13. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  14. package/dist/core/modules/migrate/migration-runner.js +4 -0
  15. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  16. package/dist/tsconfig.build.tsbuildinfo +1 -1
  17. package/migration-guides/11.25.x-to-11.26.0.md +3 -7
  18. package/migration-guides/11.32.0-to-11.32.1.md +84 -0
  19. package/migration-guides/11.32.1-to-11.32.2.md +173 -0
  20. package/package.json +1 -1
  21. package/src/core/common/interfaces/server-options.interface.ts +49 -0
  22. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +32 -10
  23. package/src/core/modules/ai/README.md +76 -14
  24. package/src/core/modules/ai/core-ai-mcp.controller.ts +28 -12
  25. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +2 -1
  26. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +12 -4
  27. package/src/core/modules/ai/models/core-ai-mode.model.ts +2 -1
  28. package/src/core/modules/ai/models/core-ai-tool-grant.model.ts +1 -1
  29. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +2 -1
  30. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +140 -7
  31. package/src/core/modules/ai/services/core-ai.service.ts +36 -13
  32. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +8 -1
  33. package/src/core/modules/migrate/migration-runner.ts +17 -0
@@ -74,13 +74,9 @@ Tools self-register in the global `AiToolRegistry`. Implement `IAiTool` (or exte
74
74
 
75
75
  #### 4. Enable the MCP server (optional)
76
76
 
77
- If you set `ai: { mcp: true }` (or `ai: { mcp: { oauth: true, oauthSecret: '…' } }`), **install the MCP SDK** in your project:
77
+ If you set `ai: { mcp: true }` (or `ai: { mcp: { oauth: true, oauthSecret: '…' } }`), no install step is needed.
78
78
 
79
- ```bash
80
- pnpm add @modelcontextprotocol/sdk
81
- ```
82
-
83
- The SDK is a peer-style optional dependency that the controller lazy-imports on the first MCP request — projects without MCP pay no install cost. When `ai.mcp` is set but the SDK is missing, `/ai/mcp` returns **503 Service Unavailable** with the install hint instead of a 500 trace.
79
+ > **Correction (11.32.2):** this guide originally instructed `pnpm add @modelcontextprotocol/sdk` and called the SDK a "peer-style optional dependency". That was wrong. It is a regular `dependency` of `@lenne.tech/nest-server` and reaches both consumption modes — npm-mode transitively, CLI-vendored projects through the dependency merge. The controller still lazy-imports it on the first MCP request, so a project without MCP pays no startup cost. A 503 from `/ai/mcp` therefore means the module could not be RESOLVED; the underlying error is in the server log.
84
80
 
85
81
  For OAuth 2.1 (generic MCP clients with auto-discovery + dynamic registration), mount the OAuth router in `main.ts`:
86
82
 
@@ -264,7 +260,7 @@ If you cast to `as any` to pass these to `CoreModule.forRoot(...)`, you can drop
264
260
  | `aiPrompt` returns "No AI service is currently available" | No usable connection exists — create one (admin) or set `AI_BASE_URL` to seed a default |
265
261
  | API keys encrypted with a dev default (warning logged) | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) in production |
266
262
  | Custom MCP controller endpoints return 404 | Ensure the MCP controller is enabled (`ai.mcp`) and OAuth is mounted in `main.ts` when using `ai.mcp.oauth` |
267
- | `/ai/mcp` returns 503 "MCP server unavailable: the @modelcontextprotocol/sdk peer dependency is not installed" | Install the SDK in your project: `pnpm add @modelcontextprotocol/sdk` (it is a peer-style optional dep, lazy-imported only when MCP is used) |
263
+ | `/ai/mcp` returns 503 | Since 11.32.2 the body carries `#LTNS_0901` and points at the log. The SDK is a regular dependency and reaches both npm and vendor mode, so this means it could not be RESOLVED, not that it is missing check the server log for the underlying error |
268
264
  | Token budgets not enforced | Budgets require `ai.audit: true` (usage is read from `aiInteractions`) |
269
265
  | `aiPrompt` returns 500 "BSONError: input must be a 24 character hex string" | A client sent `conversationId: "null"` (literal string). The orchestrator now treats `"null"`/`"undefined"` strings as "no conversation" — upgrade to the latest 11.26.x patch if you still see this |
270
266
 
@@ -0,0 +1,84 @@
1
+ # Migration Guide: 11.32.0 → 11.32.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Change | Effort |
6
+ |----------|--------|--------|
7
+ | **Bugfix** | A missing `migrations/` directory no longer blocks the server boot | None — automatic |
8
+ | **Bugfix** | A spec in `src/core/` no longer breaks vendor-mode projects | None — automatic |
9
+
10
+ **No code changes required.** Both fixes are internal.
11
+
12
+ ## Quick Migration
13
+
14
+ ```bash
15
+ pnpm update @lenne.tech/nest-server
16
+ ```
17
+
18
+ Vendor-mode projects: `/lt-dev:backend:update-nest-server-core`.
19
+
20
+ ---
21
+
22
+ ## What was fixed
23
+
24
+ ### A missing `migrations/` directory no longer blocks the boot
25
+
26
+ `MigrationRunner.loadMigrationFiles()` called `fs.readdirSync()` without checking that the
27
+ directory exists. A missing directory threw `ENOENT` — and because the standard start script is
28
+ `migrate:up && start:local`, the `&&` turned that into a server that never starts, with an error
29
+ that does not point at the cause.
30
+
31
+ This is a state people produce routinely: *"delete all migrations"* reads to most as *"throw the
32
+ folder away"*.
33
+
34
+ A missing directory now means the same thing as an empty one — there are no migrations:
35
+
36
+ ```
37
+ [migrate] migrations directory not found — treating as empty: ./migrations
38
+ ```
39
+
40
+ `up()` and `status()` continue and exit 0. **`down()` is unchanged and still fails hard** —
41
+ rollback is an explicit operator action, never a boot path, and an exit 0 with no rollback
42
+ performed would mislead scripted rollbacks. `NSC__MIGRATE__STRICT=true` still turns a
43
+ recorded-but-missing migration into a hard error, so the integrity guard is intact.
44
+
45
+ If your project kept a `migrations/.gitkeep` purely to work around this, you can drop it — though
46
+ keeping it does no harm.
47
+
48
+ > Tracked as DEV-2634.
49
+
50
+ ### A `src/core/` spec no longer breaks vendor-mode projects
51
+
52
+ `hub-mermaid.helper.spec.ts` mixed a value and a type import in one statement
53
+ (`import { buildErDiagram, type HubModelDescriptor } from …`). The lt CLI's vendor conversion drops
54
+ the inline `type` specifier, so the file arrived in vendored projects as
55
+ `import { buildErDiagram }` and failed to compile with `TS2304: Cannot find name
56
+ 'HubModelDescriptor'` — breaking `pnpm run check` in every freshly generated vendor-mode project.
57
+
58
+ The import is now split into two statements, which survives the conversion. Only vendor-mode
59
+ projects were affected; npm-mode consumers never saw it.
60
+
61
+ > The underlying CLI defect is tracked separately. Splitting the import keeps `src/core/`
62
+ > vendor-safe regardless.
63
+
64
+ ---
65
+
66
+ ## Compatibility Notes
67
+
68
+ | Pattern | Status |
69
+ |---------|:------:|
70
+ | Everything from 11.32.0 | Unchanged |
71
+ | `MigrationRunner` public API | Unchanged |
72
+ | `down()` behaviour on a missing rollback file | Unchanged (still hard error) |
73
+ | `NSC__MIGRATE__STRICT` | Unchanged |
74
+
75
+ Still relevant from the previous release: **[the two security overrides your project needs](11.31.3-to-11.32.0.md#security-add-two-overrides)** — pnpm `overrides:` are not inherited from this package. If you skipped that step, do it now.
76
+
77
+ ---
78
+
79
+ ## Troubleshooting
80
+
81
+ | Symptom | Cause | Fix |
82
+ |---------|-------|-----|
83
+ | `ENOENT: no such file or directory, scandir './migrations'` on start | Pre-11.32.1 | Update; no code change needed |
84
+ | `TS2304: Cannot find name 'HubModelDescriptor'` in a vendored core | Pre-11.32.1 vendored copy | Re-sync the core, or split the import in that one spec by hand |
@@ -0,0 +1,173 @@
1
+ # Migration Guide: 11.32.1 → 11.32.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `ai.deferToolSummaryChars` — cap tool descriptions in the deferred tool catalog |
9
+ | **Bugfixes** | Quadratic-backtracking regex in the tool-catalog builder (event-loop stall); three orchestrator messages were English-only; `context` was not framed as untrusted; misleading MCP 503 message |
10
+ | **Migration Effort** | ~5 minutes — read §2 if you assert on AI response text, §3 if you use `deferToolSchemas` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.32.2
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ No configuration change is required. Everything below is either opt-in or a
23
+ correction you inherit automatically.
24
+
25
+ ---
26
+
27
+ ## 1. Fixed: event-loop stall in the tool-catalog builder
28
+
29
+ `CoreAiPromptBuilderService` used a sentence-splitting regex whose leading greedy
30
+ character class made it **quadratic** in the description length. A tool description
31
+ of 100 KB without a sentence terminator blocked the event loop for **~6.7 seconds**
32
+ — on every prompt build, single-threaded, for the whole API.
33
+
34
+ This is relevant to you if tool descriptions can come from outside your codebase:
35
+ `CoreAiMcpClientService.buildWrapperTool` adopts whatever a remote MCP server
36
+ advertises, uncapped.
37
+
38
+ Fixed by matching the terminator via lookahead instead. Runtime is now bounded by
39
+ the configured cap rather than by input length (100 KB: 6.7 s → 0.02 ms).
40
+
41
+ **Action: none.** Behaviour is unchanged for realistic descriptions. The only inputs
42
+ that summarise differently are those that *begin* with punctuation or place a
43
+ terminator directly after whitespace — the result stays a prefix of the original and
44
+ still respects the cap.
45
+
46
+ ---
47
+
48
+ ## 2. Three orchestrator messages are now translated
49
+
50
+ These were hard-coded English regardless of the requested language:
51
+
52
+ | Situation | Now returns |
53
+ |-----------|-------------|
54
+ | Confirmation required for an action | `translate('confirm_required')` |
55
+ | No final answer within `maxIterations` | `translate('no_final_answer')` |
56
+ | Action blocked by policy | `translate('blocked_by_policy')` — the English `\|\|` fallback was removed |
57
+
58
+ **Action required if** your frontend or tests match on the English strings — e.g.
59
+ `expect(response.text).toContain('could not produce a final answer')`. With
60
+ `language: 'de'` these now return German. Assert on `requiresConfirmation`,
61
+ `actions` or the response shape instead of on prose.
62
+
63
+ ---
64
+
65
+ ## 3. New: `ai.deferToolSummaryChars` (opt-in, default `0`)
66
+
67
+ Caps each tool description in the **deferred** catalog (`deferToolSchemas: true`).
68
+
69
+ ```typescript
70
+ ai: {
71
+ deferToolSchemas: true,
72
+ deferToolSummaryChars: 300, // 0 (default) = untruncated
73
+ }
74
+ ```
75
+
76
+ With a large registry the descriptions alone can re-inflate the prompt that
77
+ `deferToolSchemas` was meant to shrink. Whole sentences are kept up to the cap, a
78
+ `…` marks the cut (appended **on top of** the cap, so a shortened entry is
79
+ `cap + 1` characters), and `search_tools` still returns the full text.
80
+
81
+ **The default `0` is deliberate**: enabling `deferToolSchemas` alone never changes
82
+ what a description says. The saving is therefore opt-in — set both together.
83
+
84
+ Two scoping notes:
85
+
86
+ - In **auto mode**, a connection with `supportsNativeTools: true` receives full
87
+ descriptions and schemas through the native `tools` payload anyway, so truncation
88
+ and the `search_tools` banner are skipped there.
89
+ - **Plan mode always uses the emulated protocol**, so a native connection *is*
90
+ truncated there — and since plan mode produces a complete plan in one call without
91
+ executing, it cannot follow the `search_tools` hint. Keep the cap generous if you
92
+ rely on plan mode.
93
+
94
+ > The truncated tail is typically where preconditions and role limits are written.
95
+ > This is model **guidance only** — authorization is enforced by the registry role
96
+ > filter, the execution-time re-check and the `mutating`/`destructive` flags, none of
97
+ > which read the description.
98
+
99
+ ---
100
+
101
+ ## 4. `input.context` is now framed as UNTRUSTED
102
+
103
+ `appendClientContext()` previously labelled the structured `context` block
104
+ `Context (structured):` while `metadata` was explicitly marked untrusted. Both
105
+ arrive on the same request from the same client, so the asymmetry invited smuggling
106
+ instructions through the half that read as trusted. Both blocks now carry identical
107
+ UNTRUSTED framing, and U+2028/U+2029 (which `JSON.stringify` does **not** escape) are
108
+ neutralised so a client cannot fake a line break out of its block.
109
+
110
+ **Action required if** you have a prompt fragment, `aiSlots` override or test that
111
+ matches the old `Context (structured):` label.
112
+
113
+ ---
114
+
115
+ ## 5. Corrected documentation: `AiTool.authorize()` runs in PLAN MODE only
116
+
117
+ No code change — but the docs previously implied `authorize()` was a general
118
+ enforcement layer. It is not: auto mode (the default) and the MCP endpoint call
119
+ `execute()` directly.
120
+
121
+ **Check your tools.** A data-level check (ownership, tenant scope) that lives *only*
122
+ in `authorize()` does not run for most callers. Put it inside `execute()`, routed
123
+ through `CrudService` with `context.serviceOptions`, and treat `authorize()` as the
124
+ plan-mode pre-flight that can reject a whole plan before any step runs.
125
+
126
+ > Also note there is **no confirmation gate over MCP**: `mcpCallTool` does not consult
127
+ > the `mutating`/`destructive` flags. A destructive tool invoked through `/ai/mcp`
128
+ > executes immediately.
129
+
130
+ ---
131
+
132
+ ## 6. MCP 503 message changed shape
133
+
134
+ `/ai/mcp` returned an install hint claiming `@modelcontextprotocol/sdk` was a missing
135
+ "peer-style optional dependency". That was wrong: it is a regular `dependency` and
136
+ reaches both consumption modes (npm-mode transitively, CLI-vendored projects via the
137
+ dependency merge). A failure there is a **resolution** problem, not an absent package.
138
+
139
+ The body now carries the stable `#LTNS_0901` ErrorCode and points at the server log.
140
+ The underlying error (which contains filesystem paths) stays out of the response.
141
+
142
+ **Action required if** you match on the old message text. Branch on the ErrorCode.
143
+
144
+ ---
145
+
146
+ ## 7. Test-runner change (framework repo only)
147
+
148
+ `restoreMocks: true` was added to `vitest.config.ts` and `vitest-e2e.config.ts`.
149
+ These files are **not** part of the vendored file set, so vendor-mode consumers are
150
+ unaffected. Adopting it in your own project is recommended: without it, a
151
+ `vi.spyOn` whose manual `mockRestore()` is skipped by a throwing assertion stays
152
+ installed for the rest of the worker. Note vitest restores in `onBeforeTryTask`,
153
+ i.e. **before** each attempt — a spy installed in `beforeAll` will not survive.
154
+
155
+ ---
156
+
157
+ ## Module Documentation
158
+
159
+ - [AI module README](../src/core/modules/ai/README.md) — see "Deferred tool schemas (large registries)"
160
+ - [AI INTEGRATION-CHECKLIST](../src/core/modules/ai/INTEGRATION-CHECKLIST.md) — advanced configuration
161
+ - [Configurable features](../.claude/rules/configurable-features.md) — the Numeric Sentinel pattern
162
+
163
+ ---
164
+
165
+ ## Troubleshooting
166
+
167
+ | Symptom | Cause | Fix |
168
+ |---------|-------|-----|
169
+ | Test asserts on English AI text and fails | §2 — messages are translated now | Assert on `requiresConfirmation` / `actions`, not prose |
170
+ | `deferToolSummaryChars` has no effect | `deferToolSchemas` is off — the framework logs a warning once | Enable both together |
171
+ | Tool descriptions truncated on a native connection | Plan mode always uses the emulated protocol (§3) | Raise the cap, or set `deferToolSummaryChars: 0` |
172
+ | `/ai/mcp` returns 503 | The SDK could not be **resolved** (it is installed) | Check the server log for the underlying resolution error |
173
+ | A prompt fragment no longer matches | §4 — the `context` label changed | Update the fragment / `aiSlots` override |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.32.0",
3
+ "version": "11.32.2",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -1294,6 +1294,55 @@ export interface IAi {
1294
1294
  */
1295
1295
  deferToolSchemas?: boolean;
1296
1296
 
1297
+ /**
1298
+ * Maximum characters per tool description in the DEFERRED catalog
1299
+ * (`deferToolSchemas: true`). Whole sentences are kept up to this cap (always
1300
+ * at least the first one); when the first sentence alone exceeds the cap the
1301
+ * text is cut on a word boundary. A shortened description always ends in `…`,
1302
+ * which is appended ON TOP of the cap — a shortened entry is therefore
1303
+ * `deferToolSummaryChars + 1` characters, not exactly the cap. The full text
1304
+ * stays available through the `search_tools` meta-tool.
1305
+ *
1306
+ * Without a cap a large tool registry re-inflates the very prompt that
1307
+ * `deferToolSchemas` was meant to shrink: with enough tools the descriptions
1308
+ * alone can outweigh the schemas they replaced and consume the majority of a
1309
+ * small context window. With a cap, mind that the omitted tail is where tool
1310
+ * authors typically put preconditions and role restrictions; the catalog
1311
+ * banner tells the model to fetch the full text before calling such a tool.
1312
+ * Note this is model GUIDANCE only — it never relaxes authorization. Which tools
1313
+ * a caller sees and may run is decided server-side by the registry's role filter
1314
+ * (`AiToolRegistry.forUser()`), re-checked at execution time, and the
1315
+ * confirmation gate reads the `mutating`/`destructive` FLAGS, never the
1316
+ * description text. (`AiTool.authorize()` runs in plan mode only — in auto mode
1317
+ * and over MCP, data-level checks belong inside `execute()`.)
1318
+ *
1319
+ * Applies to the EMULATED tool protocol, not to a provider as such. In auto mode
1320
+ * a connection with native tool calling receives every full description and schema
1321
+ * through `buildToolSchemas()` regardless, so no truncation is applied there and no
1322
+ * `search_tools` banner is emitted — capping would otherwise assert a truncation
1323
+ * that the tool payload right next to it contradicts.
1324
+ *
1325
+ * **Plan mode always uses the emulated protocol** (it sends no native schemas), so a
1326
+ * native connection IS truncated there. Because plan mode returns a complete plan in
1327
+ * one call and executes nothing, the model cannot act on a `search_tools` hint before
1328
+ * committing — the catalog therefore carries a different banner there, telling it the
1329
+ * descriptions are abbreviated and to plan conservatively. The dropped tail stays
1330
+ * unrecoverable for planning, so keep the cap generous if you rely on plan mode.
1331
+ *
1332
+ * `0` (the default) keeps the untruncated descriptions, so enabling
1333
+ * `deferToolSchemas` alone never changes what a tool description says. That
1334
+ * backward-compatible default means the prompt saving is opt-in: when you turn
1335
+ * on `deferToolSchemas` to reclaim context, set this too — roughly 200–400 is
1336
+ * a good starting point, low enough to shrink a large catalog while still
1337
+ * carrying a full first sentence per tool.
1338
+ *
1339
+ * Setting this WITHOUT `deferToolSchemas` does nothing (the cap only applies to
1340
+ * the deferred catalog); the framework logs a warning once at runtime rather
1341
+ * than ignoring it silently.
1342
+ * @default 0
1343
+ */
1344
+ deferToolSummaryChars?: number;
1345
+
1297
1346
  /** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
1298
1347
  maxIterations?: number;
1299
1348
 
@@ -137,6 +137,8 @@ ai: {
137
137
  },
138
138
  contextWindow: 8192, // fallback when a connection has no auto-detected window
139
139
  maxToolResultChars: 12000, // cap tool-results fed back to the model
140
+ deferToolSchemas: false, // catalog = names + descriptions only; schemas via search_tools
141
+ deferToolSummaryChars: 0, // 0 = full descriptions; set ~200-400 WITH deferToolSchemas
140
142
  promptLearning: { autoApply: false },// governed self-improvement (admins approve learned hints)
141
143
  mcp: { oauth: true, oauthSecret: process.env.NSC__AI__ENCRYPTION_SECRET },
142
144
  }
@@ -165,21 +167,41 @@ ai: {
165
167
  - **Context window:** detected automatically per connection and persisted; no setup needed.
166
168
  Override per connection (`contextWindow`) or globally (`ai.contextWindow`) if a backend
167
169
  isn't recognized.
170
+ - **Many tools?** `deferToolSchemas: true` keeps the full JSON schemas out of the system
171
+ prompt (the model fetches one on demand via the built-in `search_tools` meta-tool). Set
172
+ `deferToolSummaryChars` (~200–400) ALONGSIDE it — otherwise the descriptions alone
173
+ re-inflate the prompt you just shrank. Both are scoped to the **emulated tool protocol**:
174
+ in auto mode a connection with `supportsNativeTools: true` gets every full description and
175
+ schema in the native `tools` payload regardless, so truncation and the banner are skipped
176
+ there. **Plan mode always uses the emulated protocol**, so a native connection IS truncated
177
+ there — and since plan mode plans in one call without executing, it cannot follow the
178
+ `search_tools` hint. Keep the cap generous if you use plan mode.
179
+ The truncated tail is typically where a tool's preconditions and role limits are written;
180
+ the model is told to fetch the full text first, but that is guidance only — authorization
181
+ stays with the registry's role filter, the execution-time re-check, and the
182
+ `mutating`/`destructive` flags behind the confirmation gate, none of which read the
183
+ description.
184
+ - **Where data-level checks belong:** `authorize()` runs in **plan mode only**. In auto mode
185
+ (the default) and over MCP, the orchestrator calls `execute()` directly. Put ownership and
186
+ tenant checks INSIDE `execute()` (via `CrudService` + `context.serviceOptions`) — a check
187
+ that lives only in `authorize()` will not run for most callers.
168
188
 
169
189
  ### MCP server (only when `ai.mcp.enabled` or `ai.mcp` is truthy)
170
190
 
171
- **Install the MCP SDK** in your project:
191
+ **No install step needed.** `@modelcontextprotocol/sdk` is a regular dependency of
192
+ `@lenne.tech/nest-server` and reaches both consumption modes — npm-mode projects
193
+ resolve it transitively, CLI-vendored projects get it merged into their own
194
+ `package.json`. `CoreAiMcpController` still `import()`s it lazily, so a project
195
+ that never enables MCP pays no startup cost.
172
196
 
173
- ```bash
174
- pnpm add @modelcontextprotocol/sdk
175
- # or: npm install @modelcontextprotocol/sdk
176
- ```
197
+ A **503 Service Unavailable** from `/ai/mcp` therefore means the module could not be
198
+ _resolved_, not that it is missing — check the server log for the underlying error.
199
+ The response body carries the stable `#LTNS_0901` code, never the raw error.
177
200
 
178
- The SDK is a peer-style optional dependency it is `import()`-ed lazily by
179
- `CoreAiMcpController` only when an MCP request actually arrives, so projects
180
- that don't enable MCP pay no install cost. When `ai.mcp` is set but the SDK
181
- is not installed, `/ai/mcp` returns **503 Service Unavailable** with an
182
- install-hint message instead of a 500 trace.
201
+ > **No confirmation gate over MCP.** `mcpCallTool` does not consult the
202
+ > `mutating`/`destructive` flags, and `authorize()` does not run there either. A
203
+ > destructive tool invoked through `/ai/mcp` executes immediately. Only expose MCP to
204
+ > clients you trust, and put every permission check inside `execute()`.
183
205
 
184
206
  ### MCP OAuth 2.1 (only when `ai.mcp.oauth` is enabled)
185
207
 
@@ -56,6 +56,8 @@ ai: {
56
56
  systemPrompt: 'You are a helpful assistant for …',
57
57
  contextWindow: 8192, // fallback when a connection has no detected window
58
58
  maxToolResultChars: 12000, // cap a tool-results payload fed back to the model
59
+ deferToolSchemas: false, // catalog lists names + descriptions only; schemas via search_tools
60
+ deferToolSummaryChars: 0, // 0 = full descriptions; set ~200-400 together with deferToolSchemas
59
61
  promptLearning: { autoApply: false }, // governed self-improvement (admin approves hints)
60
62
  // Optional one-time seed of a default connection (DB is the source of truth):
61
63
  defaultConnection: {
@@ -345,17 +347,28 @@ export class TransferFundsTool extends AiTool {
345
347
  readonly name = 'transfer_funds';
346
348
  readonly mutating = true; // governed by the confirmation policy
347
349
  readonly destructive = true; // always requires confirmation
348
- // Pre-flight check WITHOUT mutating — decides if the user may run this:
350
+
351
+ // PLAN-MODE pre-flight, without mutating: lets the whole plan be rejected before
352
+ // any step runs. It does NOT run in auto mode or over MCP — so it must never be
353
+ // the only place a permission is checked.
349
354
  async authorize(args, context) {
350
355
  const account = await this.accountService.get(args.fromId, context.serviceOptions).catch(() => null);
351
356
  return { allowed: !!account, reason: account ? undefined : 'No access to source account' };
352
357
  }
358
+
353
359
  async execute(args, context) {
354
- /* */
360
+ // The REAL gate, on every path: route through the service with the caller's
361
+ // serviceOptions so `@Restricted`, `securityCheck()` and tenant scoping apply.
362
+ // Do not rely on authorize() having run.
363
+ const account = await this.accountService.getForUser(args.fromId, context.serviceOptions);
364
+ return this.accountService.transfer(account, args.toId, args.amount, context.serviceOptions);
355
365
  }
356
366
  }
357
367
  ```
358
368
 
369
+ > **Do not put a permission check only in `authorize()`.** It runs in plan mode
370
+ > only — auto mode (the default) and MCP go straight to `execute()`.
371
+
359
372
  ## Confirmation for changes
360
373
 
361
374
  - `destructive` tools always require confirmation.
@@ -421,9 +434,10 @@ The model also receives **structured tool errors** (`{ error: { code, message, h
421
434
  so it can recover within the run.
422
435
 
423
436
  > **Security:** learned hints and template overrides only ever **add textual guidance** —
424
- > they can never relax the permission model. Tool role-filtering, `authorize()`,
437
+ > they can never relax the permission model. Tool role-filtering,
425
438
  > `CrudService`/`@Restricted` and `secretFields` are enforced backend-side regardless of
426
- > the prompt.
439
+ > the prompt. (`authorize()` is _not_ part of that unconditional set — it runs in plan
440
+ > mode only; see [Deferred tool schemas](#deferred-tool-schemas-large-registries) below.)
427
441
 
428
442
  ```typescript
429
443
  ai: {
@@ -449,6 +463,55 @@ When a user's session history would overflow, the **oldest non-system turns are
449
463
  `ai.maxToolResultChars` (default `12000`) — so long-running conversations never exceed the
450
464
  model's limit.
451
465
 
466
+ ## Deferred tool schemas (large registries)
467
+
468
+ The system prompt normally carries every tool's full JSON parameter schema. With a large
469
+ registry that catalog can dominate a small context window. Set `ai.deferToolSchemas: true`
470
+ and the catalog lists only tool **names + descriptions**; the model fetches a specific
471
+ schema on demand through the built-in `search_tools` meta-tool.
472
+
473
+ With many tools the **descriptions alone** can then re-inflate the prompt the deferral was
474
+ meant to shrink. `ai.deferToolSummaryChars` caps each description in that deferred catalog:
475
+ whole sentences up to the cap (always at least the first one), a word-boundary cut when the
476
+ first sentence already exceeds it, and a `…` marker appended **on top of** the cap.
477
+
478
+ ```typescript
479
+ ai: {
480
+ deferToolSchemas: true,
481
+ deferToolSummaryChars: 300, // 0 (default) = keep descriptions untruncated
482
+ }
483
+ ```
484
+
485
+ The default of `0` is deliberate: enabling `deferToolSchemas` alone never changes what a
486
+ tool description _says_. The saving is therefore opt-in — when you defer schemas to reclaim
487
+ context, set `deferToolSummaryChars` alongside it (roughly 200–400 works well).
488
+
489
+ > **Scoped to the emulated tool protocol, not to the provider.** In auto mode a connection
490
+ > with `supportsNativeTools: true` receives every full description and JSON schema through
491
+ > the native `tools` payload anyway, so truncation is skipped there and no `search_tools`
492
+ > banner is emitted — telling the model a description was cut while handing it the full text
493
+ > in the same request would only buy a wasted round-trip against `maxIterations`.
494
+ >
495
+ > **Plan mode always uses the emulated protocol**, so a native connection IS truncated there.
496
+ > Because plan mode returns a complete plan in one call without executing anything, the model
497
+ > cannot act on a `search_tools` hint before committing — the catalog therefore carries a
498
+ > different banner there ("descriptions are abbreviated, plan conservatively") instead of
499
+ > promising a lookup that would arrive too late. The dropped tail stays unrecoverable for
500
+ > planning, so keep the cap generous if you rely on plan mode.
501
+
502
+ > **The truncated tail is where preconditions and role restrictions usually live.** The
503
+ > catalog banner instructs the model to fetch the full text via `search_tools` before
504
+ > calling a `…`-marked tool. This is model **guidance only** — it never affects
505
+ > authorization. Which tools a user sees and may run is decided server-side by the registry's
506
+ > role filter (`AiToolRegistry.forUser()`), re-checked when the call executes; and the
507
+ > confirmation gate reads a tool's `mutating`/`destructive` **flags**, never its description
508
+ > — so a truncated "requires confirmation" sentence cannot disable it.
509
+ >
510
+ > Note `AiTool.authorize()` is **not** part of that chain in every mode: it runs in **plan
511
+ > mode only**. In auto mode (the default) and over MCP, tools go straight to `execute()` — so
512
+ > data-level checks (ownership, tenant scope) must live **inside `execute()`**, routed through
513
+ > `CrudService` with `context.serviceOptions`, not in `authorize()` alone.
514
+
452
515
  ## Token budgets & usage
453
516
 
454
517
  Token/prompt limits are **per user AND per tenant**, with **config defaults** so you
@@ -483,17 +546,16 @@ The `AiToolRegistry` also feeds a real **MCP server** at `POST/GET/DELETE /ai/mc
483
546
  (Streamable HTTP), so external MCP clients use the same backend tools with the
484
547
  same role gating. Enable with `ai: { mcp: true }`.
485
548
 
486
- **Install the SDK** in your project (it is a peer-style optional dependency that
487
- the controller lazy-imports only when an MCP request arrives — projects that
488
- don't enable MCP pay no install cost):
489
-
490
- ```bash
491
- pnpm add @modelcontextprotocol/sdk
492
- ```
549
+ **No install step needed.** `@modelcontextprotocol/sdk` ships as a regular
550
+ dependency of `@lenne.tech/nest-server` and reaches both consumption modes:
551
+ npm-mode projects resolve it transitively, and CLI-vendored projects get it
552
+ merged into their own `package.json`. The controller still `import()`s it
553
+ lazily, so a project that never enables MCP does not pay the startup cost.
493
554
 
494
- When `ai.mcp` is set but the SDK is missing, `/ai/mcp` returns **503 Service
495
- Unavailable** with an actionable install-hint message instead of a 500 stack
496
- trace.
555
+ If `/ai/mcp` nevertheless returns **503 Service Unavailable**, the module could
556
+ not be _resolved_ a bundler or test runner with its own module resolution can
557
+ fail on the subpath export while plain Node succeeds. The underlying error is in
558
+ the server log; the response carries only a stable `#LTNS_0901` code.
497
559
 
498
560
  - Auth: the request must carry a valid Bearer token/session (resolved by the
499
561
  framework's existing auth) — the MCP session is bound to that user, and
@@ -6,6 +6,7 @@ import { Roles } from '../../common/decorators/roles.decorator';
6
6
  import { RoleEnum } from '../../common/enums/role.enum';
7
7
  import { ConfigService } from '../../common/services/config.service';
8
8
  import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
9
+ import { ErrorCode } from '../error-code/error-codes';
9
10
  import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
10
11
  import { CoreAiMcpService } from './services/core-ai-mcp.service';
11
12
 
@@ -59,10 +60,13 @@ export class CoreAiMcpController {
59
60
  try {
60
61
  ({ StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js'));
61
62
  } catch (err) {
62
- // The MCP SDK is a peer-style optional dependency it must be installed
63
- // by the consumer project when `ai.mcp.enabled` is true. Surface a
64
- // 503 with an actionable hint instead of the raw "Cannot find module"
65
- // 500 that bubbles from the lazy `import()`.
63
+ // Imported lazily so a consumer that never enables MCP does not pay for
64
+ // loading the SDK at startup. It IS a regular dependency of this package and
65
+ // reaches BOTH consumption modes npm-mode transitively, CLI-vendored
66
+ // projects through the dependency merge so a failure here is a resolution
67
+ // problem, not an absent package. Surface a 503 instead of the raw
68
+ // "Cannot find module" 500 that bubbles from the lazy `import()`.
69
+ // See mcpUnavailable() below for the full reasoning.
66
70
  return this.mcpUnavailable(res, err as Error);
67
71
  }
68
72
  const { randomUUID } = await import('node:crypto');
@@ -167,19 +171,31 @@ export class CoreAiMcpController {
167
171
  }
168
172
 
169
173
  /**
170
- * 503 Service Unavailable when the optional `@modelcontextprotocol/sdk` peer
171
- * dependency is not installed. The SDK is lazy-imported because not every
172
- * consumer needs MCP — when `ai.mcp.enabled` is set but the SDK is missing,
173
- * we surface the actionable install hint rather than the raw require-stack
174
- * trace from the failed `import()`.
174
+ * 503 Service Unavailable when `@modelcontextprotocol/sdk` cannot be loaded.
175
+ * The SDK is lazy-imported because not every consumer needs MCP — when
176
+ * `ai.mcp.enabled` is set but the import fails, we surface an actionable hint
177
+ * rather than the raw require-stack trace from the failed `import()`.
178
+ *
179
+ * The SDK is a regular `dependency` of this package and reaches BOTH consumption
180
+ * modes: npm-mode consumers resolve it transitively, and CLI-vendored projects get
181
+ * it merged into their own `package.json` (`convertCloneToVendored()` copies every
182
+ * upstream dependency, and the import-closure scan additionally backfills bare
183
+ * specifiers found in dynamic `import()` calls). A failure here is therefore
184
+ * almost always a RESOLUTION problem — a bundler or test runner with its own
185
+ * module resolution can fail on the subpath export while plain Node succeeds — not
186
+ * a genuinely absent package.
187
+ *
188
+ * The underlying error goes to the log only, never into the response: it carries
189
+ * filesystem paths.
175
190
  */
176
191
  private mcpUnavailable(res: Response, err: Error): void {
177
192
  this.logger.error(`MCP SDK not available: ${err.message}`);
178
193
  res.status(503).json({
179
194
  error:
180
- 'MCP server unavailable: the @modelcontextprotocol/sdk peer dependency is not installed. ' +
181
- 'Run `pnpm add @modelcontextprotocol/sdk` (or `npm i @modelcontextprotocol/sdk`) ' +
182
- 'in your project and restart the server.',
195
+ `${ErrorCode.SERVICE_UNAVAILABLE} — MCP server unavailable: ` +
196
+ '@modelcontextprotocol/sdk could not be loaded. It ships as a dependency of ' +
197
+ '@lenne.tech/nest-server, so this usually means the module could not be resolved ' +
198
+ 'rather than that it is missing; see the server log for the underlying error.',
183
199
  statusCode: 503,
184
200
  });
185
201
  }
@@ -38,7 +38,8 @@ export interface AiHookEvent {
38
38
  *
39
39
  * **Security:** hooks can only ADD restrictions (block calls, redact args) — they
40
40
  * cannot relax the permission system. A hook returning no block does not bypass
41
- * `@Restricted`/`@Roles`/`authorize()`; those still apply.
41
+ * `@Restricted`/`@Roles`/`securityCheck()`; those still apply on every path
42
+ * (`authorize()` only runs in plan mode).
42
43
  */
43
44
  export interface IAiHook {
44
45
  /** Unique hook name (for diagnostics and deterministic ordering). */