@lenne.tech/nest-server 11.32.1 → 11.32.3

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 (36) hide show
  1. package/.claude/rules/configurable-features.md +52 -1
  2. package/FRAMEWORK-API.md +4 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -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/inputs/core-ai-connection.input.js +2 -0
  7. package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
  8. package/dist/core/modules/ai/models/core-ai-mode.model.js.map +1 -1
  9. package/dist/core/modules/ai/models/core-ai-tool-policy.model.js.map +1 -1
  10. package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
  11. package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
  12. package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
  13. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.d.ts +6 -1
  14. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +66 -7
  15. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  16. package/dist/core/modules/ai/services/core-ai.service.d.ts +1 -0
  17. package/dist/core/modules/ai/services/core-ai.service.js +13 -10
  18. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  19. package/dist/tsconfig.build.tsbuildinfo +1 -1
  20. package/migration-guides/11.25.x-to-11.26.0.md +3 -7
  21. package/migration-guides/11.32.1-to-11.32.2.md +173 -0
  22. package/migration-guides/11.32.2-to-11.32.3.md +129 -0
  23. package/package.json +1 -1
  24. package/src/core/common/interfaces/server-options.interface.ts +72 -0
  25. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +32 -10
  26. package/src/core/modules/ai/README.md +82 -14
  27. package/src/core/modules/ai/core-ai-mcp.controller.ts +28 -12
  28. package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
  29. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +2 -1
  30. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +30 -7
  31. package/src/core/modules/ai/models/core-ai-mode.model.ts +2 -1
  32. package/src/core/modules/ai/models/core-ai-tool-grant.model.ts +1 -1
  33. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +2 -1
  34. package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
  35. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +140 -7
  36. package/src/core/modules/ai/services/core-ai.service.ts +36 -13
@@ -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,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 |
@@ -0,0 +1,129 @@
1
+ # Migration Guide: 11.32.2 → 11.32.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `ai.capabilityDriftCheck` — opt-in boot warning when an explicit connection capability contradicts the endpoint; `ai.defaultConnection.contextWindow` — seed a connection's context window from config |
9
+ | **Bugfixes** | The AI connection `contextWindow` admin input now rejects non-positive / non-integer values |
10
+ | **Migration Effort** | ~2 minutes — everything is opt-in; read §3 only if your admin tooling writes `contextWindow` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.32.3
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ No configuration change is required. Everything below is opt-in or a
23
+ tightening you inherit automatically.
24
+
25
+ ---
26
+
27
+ ## 1. New: `ai.capabilityDriftCheck` (opt-in, default `false`)
28
+
29
+ A connection's `supportsNativeTools` / `supportsJsonResponse` flags are
30
+ auto-detected while they are left **undefined**, but an **explicit** value is
31
+ authoritative and is never re-probed. A wrong explicit flag therefore degrades the
32
+ assistant silently — e.g. `supportsNativeTools: false` on a backend that *does*
33
+ support native function calling forces fragile emulated tool-calling.
34
+
35
+ Enable this opt-in boot self-check to surface such a mismatch:
36
+
37
+ ```typescript
38
+ ai: {
39
+ capabilityDriftCheck: true, // default false
40
+ }
41
+ ```
42
+
43
+ When enabled, at startup the module probes each enabled connection that declares an
44
+ explicit flag and logs a **warning** on mismatch. It never changes the stored value
45
+ (your explicit choice stays authoritative — clear the flag in the admin UI to
46
+ re-enable auto-detection).
47
+
48
+ **It is off by default because it makes outbound calls to the LLM endpoints on
49
+ every boot** (and is additionally skipped in the `ci`/`e2e` runners). Note this is a
50
+ diagnostic log only: if you deliberately override a flag against what the endpoint
51
+ reports (e.g. native tools are advertised but unreliable on your model), the warning
52
+ is expected and can be ignored.
53
+
54
+ **Action: none** unless you want the diagnostic — then set the flag.
55
+
56
+ ---
57
+
58
+ ## 2. New: `ai.defaultConnection.contextWindow`
59
+
60
+ The one-time `ai.defaultConnection` seed now accepts `contextWindow`, so a
61
+ config-seeded connection can carry its context window from the start instead of
62
+ relying on auto-detection:
63
+
64
+ ```typescript
65
+ ai: {
66
+ defaultConnection: {
67
+ name: 'default',
68
+ baseUrl: '...',
69
+ model: '...',
70
+ contextWindow: 32768, // optional — omit to auto-detect
71
+ },
72
+ }
73
+ ```
74
+
75
+ Omit it to keep the existing behaviour (auto-detect by probing the endpoint /
76
+ `knownContextWindow()`, falling back to the global `ai.contextWindow` default of
77
+ 8192).
78
+
79
+ **Action: none** — additive and optional.
80
+
81
+ ---
82
+
83
+ ## 3. Tightened: `contextWindow` admin input validation
84
+
85
+ The `contextWindow` field on the AI connection create/update input is now validated
86
+ as a **positive integer** (`@IsInt` + `@Min(1)`). Previously any number (including
87
+ `0`, a negative, or a float) was accepted verbatim, and an explicit `0` is not
88
+ "unset" — it would have fed the orchestrator's context budget as a real (broken)
89
+ value instead of falling back to the safe default.
90
+
91
+ **Action required if** your admin tooling or tests send a non-integer or
92
+ `< 1` `contextWindow` to `createAiConnection` / `updateAiConnection` — those calls
93
+ now return a validation error. Send a positive integer, or omit the field to
94
+ auto-detect.
95
+
96
+ ---
97
+
98
+ ## 4. Documentation: the MCP confirmation-gate boundary
99
+
100
+ No code change — a clarification of the `IAiTool.destructive` / `.mutating` JSDoc
101
+ (reinforcing §5 of the 11.32.2 guide). Over `/ai/mcp` there is **no confirmation
102
+ gate**: `mcpCallTool` consults neither flag, so a destructive tool executes on the
103
+ first call. The barriers that *do* hold on every path are the registry **role
104
+ filter** (`forUser()`, applied before `execute()`) and the authorization inside
105
+ `execute()` itself — so a destructive tool restricted to a real role stays
106
+ unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
107
+ step for clients that may already see the tool.
108
+
109
+ **Action:** expose `/ai/mcp` only to clients you trust to obtain user consent
110
+ themselves, and keep data-level authorization inside `execute()` (not only in the
111
+ plan-mode `authorize()`).
112
+
113
+ ---
114
+
115
+ ## Module Documentation
116
+
117
+ - [AI module README](../src/core/modules/ai/README.md) — see "Capability auto-detection"
118
+ - [AI INTEGRATION-CHECKLIST](../src/core/modules/ai/INTEGRATION-CHECKLIST.md) — advanced configuration
119
+ - [Configurable features](../.claude/rules/configurable-features.md) — the AI Assistant row
120
+
121
+ ---
122
+
123
+ ## Troubleshooting
124
+
125
+ | Symptom | Cause | Fix |
126
+ |---------|-------|-----|
127
+ | Boot logs "capability drift" for a connection | §1 — an explicit flag disagrees with the endpoint | Correct the flag in the admin UI, or clear it to auto-detect; ignore if the override is deliberate |
128
+ | `createAiConnection` rejects `contextWindow` | §3 — it must be a positive integer now | Send an integer `>= 1`, or omit to auto-detect |
129
+ | A destructive tool ran over `/ai/mcp` without confirmation | §4 — MCP has no confirmation gate by design | Only expose MCP to trusted clients; enforce data-level checks in `execute()` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.32.1",
3
+ "version": "11.32.3",
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",
@@ -1110,6 +1110,16 @@ export interface IAiDefaultConnection {
1110
1110
  /** Capability tags (free-form, e.g. 'analysis', 'vision'). */
1111
1111
  capabilities?: string[];
1112
1112
 
1113
+ /**
1114
+ * Total context window (input + output tokens) the model supports. Drives the
1115
+ * orchestrator's context budget (system prompt + history + tool results). Omit to
1116
+ * auto-detect by probing the endpoint / `knownContextWindow()`; set it explicitly
1117
+ * when the endpoint exposes no limit and the model id is unknown to the heuristic
1118
+ * (otherwise the orchestrator assumes the conservative `ai.contextWindow` default
1119
+ * of 8192 and trims the prompt + tool results on every turn).
1120
+ */
1121
+ contextWindow?: number;
1122
+
1113
1123
  /** Default maximum number of tokens for completions. */
1114
1124
  defaultMaxTokens?: number;
1115
1125
 
@@ -1212,6 +1222,19 @@ export interface IAi {
1212
1222
  user?: { maxPrompts?: number; maxTokens?: number };
1213
1223
  };
1214
1224
 
1225
+ /**
1226
+ * Opt-in boot self-check: after startup, probe each enabled connection that declares
1227
+ * an EXPLICIT `supportsNativeTools` / `supportsJsonResponse` and warn (log only) when
1228
+ * the declared value contradicts what the endpoint actually reports — a wrong explicit
1229
+ * flag otherwise silently degrades the assistant (e.g. forcing fragile emulated
1230
+ * tool-calling on a backend that supports native function calling). OFF by default
1231
+ * because it makes outbound calls to the LLM endpoints on every boot; the declared
1232
+ * value is never changed (clear it in the admin UI to re-enable auto-detection). Also
1233
+ * skipped in the ci/e2e runners.
1234
+ * @default false
1235
+ */
1236
+ capabilityDriftCheck?: boolean;
1237
+
1215
1238
  /**
1216
1239
  * Confirmation policy for mutating tool actions (create/update/delete).
1217
1240
  * `destructive` tools always require confirmation regardless of this policy.
@@ -1294,6 +1317,55 @@ export interface IAi {
1294
1317
  */
1295
1318
  deferToolSchemas?: boolean;
1296
1319
 
1320
+ /**
1321
+ * Maximum characters per tool description in the DEFERRED catalog
1322
+ * (`deferToolSchemas: true`). Whole sentences are kept up to this cap (always
1323
+ * at least the first one); when the first sentence alone exceeds the cap the
1324
+ * text is cut on a word boundary. A shortened description always ends in `…`,
1325
+ * which is appended ON TOP of the cap — a shortened entry is therefore
1326
+ * `deferToolSummaryChars + 1` characters, not exactly the cap. The full text
1327
+ * stays available through the `search_tools` meta-tool.
1328
+ *
1329
+ * Without a cap a large tool registry re-inflates the very prompt that
1330
+ * `deferToolSchemas` was meant to shrink: with enough tools the descriptions
1331
+ * alone can outweigh the schemas they replaced and consume the majority of a
1332
+ * small context window. With a cap, mind that the omitted tail is where tool
1333
+ * authors typically put preconditions and role restrictions; the catalog
1334
+ * banner tells the model to fetch the full text before calling such a tool.
1335
+ * Note this is model GUIDANCE only — it never relaxes authorization. Which tools
1336
+ * a caller sees and may run is decided server-side by the registry's role filter
1337
+ * (`AiToolRegistry.forUser()`), re-checked at execution time, and the
1338
+ * confirmation gate reads the `mutating`/`destructive` FLAGS, never the
1339
+ * description text. (`AiTool.authorize()` runs in plan mode only — in auto mode
1340
+ * and over MCP, data-level checks belong inside `execute()`.)
1341
+ *
1342
+ * Applies to the EMULATED tool protocol, not to a provider as such. In auto mode
1343
+ * a connection with native tool calling receives every full description and schema
1344
+ * through `buildToolSchemas()` regardless, so no truncation is applied there and no
1345
+ * `search_tools` banner is emitted — capping would otherwise assert a truncation
1346
+ * that the tool payload right next to it contradicts.
1347
+ *
1348
+ * **Plan mode always uses the emulated protocol** (it sends no native schemas), so a
1349
+ * native connection IS truncated there. Because plan mode returns a complete plan in
1350
+ * one call and executes nothing, the model cannot act on a `search_tools` hint before
1351
+ * committing — the catalog therefore carries a different banner there, telling it the
1352
+ * descriptions are abbreviated and to plan conservatively. The dropped tail stays
1353
+ * unrecoverable for planning, so keep the cap generous if you rely on plan mode.
1354
+ *
1355
+ * `0` (the default) keeps the untruncated descriptions, so enabling
1356
+ * `deferToolSchemas` alone never changes what a tool description says. That
1357
+ * backward-compatible default means the prompt saving is opt-in: when you turn
1358
+ * on `deferToolSchemas` to reclaim context, set this too — roughly 200–400 is
1359
+ * a good starting point, low enough to shrink a large catalog while still
1360
+ * carrying a full first sentence per tool.
1361
+ *
1362
+ * Setting this WITHOUT `deferToolSchemas` does nothing (the cap only applies to
1363
+ * the deferred catalog); the framework logs a warning once at runtime rather
1364
+ * than ignoring it silently.
1365
+ * @default 0
1366
+ */
1367
+ deferToolSummaryChars?: number;
1368
+
1297
1369
  /** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
1298
1370
  maxIterations?: number;
1299
1371
 
@@ -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: {
@@ -109,6 +111,12 @@ never probed). Detection runs in two complementary ways:
109
111
  once, persists, and uses the result. Until then the safe emulated baseline applies.
110
112
  - **On demand:** admins can re-probe via `detectAiConnectionCapabilities` /
111
113
  `POST /ai/connections/:id/detect-capabilities` (e.g. after changing `baseUrl`/`model`).
114
+ - **Boot drift check (opt-in):** set `ai.capabilityDriftCheck: true` to probe every enabled
115
+ connection that has an EXPLICIT flag once at startup and log a warning when the declared
116
+ value contradicts what the endpoint reports (a wrong explicit flag otherwise degrades the
117
+ assistant silently, e.g. forcing emulated tool-calling on a native-capable backend). It only
118
+ warns — the stored value is never changed. OFF by default because it makes outbound calls to
119
+ the LLM endpoints on every boot; also skipped in the ci/e2e runners.
112
120
 
113
121
  The probe is provider-agnostic best effort: `response_format: json_object` is sent
114
122
  (2xx → JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
@@ -345,17 +353,28 @@ export class TransferFundsTool extends AiTool {
345
353
  readonly name = 'transfer_funds';
346
354
  readonly mutating = true; // governed by the confirmation policy
347
355
  readonly destructive = true; // always requires confirmation
348
- // Pre-flight check WITHOUT mutating — decides if the user may run this:
356
+
357
+ // PLAN-MODE pre-flight, without mutating: lets the whole plan be rejected before
358
+ // any step runs. It does NOT run in auto mode or over MCP — so it must never be
359
+ // the only place a permission is checked.
349
360
  async authorize(args, context) {
350
361
  const account = await this.accountService.get(args.fromId, context.serviceOptions).catch(() => null);
351
362
  return { allowed: !!account, reason: account ? undefined : 'No access to source account' };
352
363
  }
364
+
353
365
  async execute(args, context) {
354
- /* */
366
+ // The REAL gate, on every path: route through the service with the caller's
367
+ // serviceOptions so `@Restricted`, `securityCheck()` and tenant scoping apply.
368
+ // Do not rely on authorize() having run.
369
+ const account = await this.accountService.getForUser(args.fromId, context.serviceOptions);
370
+ return this.accountService.transfer(account, args.toId, args.amount, context.serviceOptions);
355
371
  }
356
372
  }
357
373
  ```
358
374
 
375
+ > **Do not put a permission check only in `authorize()`.** It runs in plan mode
376
+ > only — auto mode (the default) and MCP go straight to `execute()`.
377
+
359
378
  ## Confirmation for changes
360
379
 
361
380
  - `destructive` tools always require confirmation.
@@ -421,9 +440,10 @@ The model also receives **structured tool errors** (`{ error: { code, message, h
421
440
  so it can recover within the run.
422
441
 
423
442
  > **Security:** learned hints and template overrides only ever **add textual guidance** —
424
- > they can never relax the permission model. Tool role-filtering, `authorize()`,
443
+ > they can never relax the permission model. Tool role-filtering,
425
444
  > `CrudService`/`@Restricted` and `secretFields` are enforced backend-side regardless of
426
- > the prompt.
445
+ > the prompt. (`authorize()` is _not_ part of that unconditional set — it runs in plan
446
+ > mode only; see [Deferred tool schemas](#deferred-tool-schemas-large-registries) below.)
427
447
 
428
448
  ```typescript
429
449
  ai: {
@@ -449,6 +469,55 @@ When a user's session history would overflow, the **oldest non-system turns are
449
469
  `ai.maxToolResultChars` (default `12000`) — so long-running conversations never exceed the
450
470
  model's limit.
451
471
 
472
+ ## Deferred tool schemas (large registries)
473
+
474
+ The system prompt normally carries every tool's full JSON parameter schema. With a large
475
+ registry that catalog can dominate a small context window. Set `ai.deferToolSchemas: true`
476
+ and the catalog lists only tool **names + descriptions**; the model fetches a specific
477
+ schema on demand through the built-in `search_tools` meta-tool.
478
+
479
+ With many tools the **descriptions alone** can then re-inflate the prompt the deferral was
480
+ meant to shrink. `ai.deferToolSummaryChars` caps each description in that deferred catalog:
481
+ whole sentences up to the cap (always at least the first one), a word-boundary cut when the
482
+ first sentence already exceeds it, and a `…` marker appended **on top of** the cap.
483
+
484
+ ```typescript
485
+ ai: {
486
+ deferToolSchemas: true,
487
+ deferToolSummaryChars: 300, // 0 (default) = keep descriptions untruncated
488
+ }
489
+ ```
490
+
491
+ The default of `0` is deliberate: enabling `deferToolSchemas` alone never changes what a
492
+ tool description _says_. The saving is therefore opt-in — when you defer schemas to reclaim
493
+ context, set `deferToolSummaryChars` alongside it (roughly 200–400 works well).
494
+
495
+ > **Scoped to the emulated tool protocol, not to the provider.** In auto mode a connection
496
+ > with `supportsNativeTools: true` receives every full description and JSON schema through
497
+ > the native `tools` payload anyway, so truncation is skipped there and no `search_tools`
498
+ > banner is emitted — telling the model a description was cut while handing it the full text
499
+ > in the same request would only buy a wasted round-trip against `maxIterations`.
500
+ >
501
+ > **Plan mode always uses the emulated protocol**, so a native connection IS truncated there.
502
+ > Because plan mode returns a complete plan in one call without executing anything, the model
503
+ > cannot act on a `search_tools` hint before committing — the catalog therefore carries a
504
+ > different banner there ("descriptions are abbreviated, plan conservatively") instead of
505
+ > promising a lookup that would arrive too late. The dropped tail stays unrecoverable for
506
+ > planning, so keep the cap generous if you rely on plan mode.
507
+
508
+ > **The truncated tail is where preconditions and role restrictions usually live.** The
509
+ > catalog banner instructs the model to fetch the full text via `search_tools` before
510
+ > calling a `…`-marked tool. This is model **guidance only** — it never affects
511
+ > authorization. Which tools a user sees and may run is decided server-side by the registry's
512
+ > role filter (`AiToolRegistry.forUser()`), re-checked when the call executes; and the
513
+ > confirmation gate reads a tool's `mutating`/`destructive` **flags**, never its description
514
+ > — so a truncated "requires confirmation" sentence cannot disable it.
515
+ >
516
+ > Note `AiTool.authorize()` is **not** part of that chain in every mode: it runs in **plan
517
+ > mode only**. In auto mode (the default) and over MCP, tools go straight to `execute()` — so
518
+ > data-level checks (ownership, tenant scope) must live **inside `execute()`**, routed through
519
+ > `CrudService` with `context.serviceOptions`, not in `authorize()` alone.
520
+
452
521
  ## Token budgets & usage
453
522
 
454
523
  Token/prompt limits are **per user AND per tenant**, with **config defaults** so you
@@ -483,17 +552,16 @@ The `AiToolRegistry` also feeds a real **MCP server** at `POST/GET/DELETE /ai/mc
483
552
  (Streamable HTTP), so external MCP clients use the same backend tools with the
484
553
  same role gating. Enable with `ai: { mcp: true }`.
485
554
 
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
- ```
555
+ **No install step needed.** `@modelcontextprotocol/sdk` ships as a regular
556
+ dependency of `@lenne.tech/nest-server` and reaches both consumption modes:
557
+ npm-mode projects resolve it transitively, and CLI-vendored projects get it
558
+ merged into their own `package.json`. The controller still `import()`s it
559
+ lazily, so a project that never enables MCP does not pay the startup cost.
493
560
 
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.
561
+ If `/ai/mcp` nevertheless returns **503 Service Unavailable**, the module could
562
+ not be _resolved_ a bundler or test runner with its own module resolution can
563
+ fail on the subpath export while plain Node succeeds. The underlying error is in
564
+ the server log; the response carries only a stable `#LTNS_0901` code.
497
565
 
498
566
  - Auth: the request must carry a valid Bearer token/session (resolved by the
499
567
  framework's existing auth) — the MCP session is bound to that user, and