@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.
- package/.claude/rules/configurable-features.md +52 -1
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-mode.model.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-tool-policy.model.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.d.ts +6 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +66 -7
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +1 -0
- package/dist/core/modules/ai/services/core-ai.service.js +13 -10
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.25.x-to-11.26.0.md +3 -7
- package/migration-guides/11.32.0-to-11.32.1.md +84 -0
- package/migration-guides/11.32.1-to-11.32.2.md +173 -0
- package/package.json +1 -1
- package/src/core/common/interfaces/server-options.interface.ts +49 -0
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +32 -10
- package/src/core/modules/ai/README.md +76 -14
- package/src/core/modules/ai/core-ai-mcp.controller.ts +28 -12
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +2 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +12 -4
- package/src/core/modules/ai/models/core-ai-mode.model.ts +2 -1
- package/src/core/modules/ai/models/core-ai-tool-grant.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +2 -1
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +140 -7
- package/src/core/modules/ai/services/core-ai.service.ts +36 -13
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +8 -1
- 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: '…' } }`),
|
|
77
|
+
If you set `ai: { mcp: true }` (or `ai: { mcp: { oauth: true, oauthSecret: '…' } }`), no install step is needed.
|
|
78
78
|
|
|
79
|
-
|
|
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
|
|
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.
|
|
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
|
-
**
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
**
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
181
|
-
'
|
|
182
|
-
'
|
|
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`/`
|
|
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). */
|