@lenne.tech/nest-server 12.2.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.claude/rules/better-auth.md +31 -4
  2. package/.claude/rules/configurable-features.md +7 -6
  3. package/.claude/rules/package-management.md +13 -1
  4. package/.claude/rules/persistence.md +2 -2
  5. package/.claude/rules/testing.md +5 -5
  6. package/.claude/rules/versioning.md +13 -2
  7. package/CLAUDE.md +5 -0
  8. package/FRAMEWORK-API.md +4 -3
  9. package/README.md +2 -2
  10. package/dist/config.env.js +2 -2
  11. package/dist/config.env.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
  13. package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
  14. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  15. package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
  16. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
  18. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  19. package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
  20. package/dist/core/modules/ai/core-ai.constants.js +2 -0
  21. package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
  22. package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
  23. package/dist/core/modules/ai/core-ai.controller.js +31 -1
  24. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  25. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  26. package/dist/core/modules/ai/core-ai.module.js +8 -1
  27. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  28. package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
  29. package/dist/core/modules/ai/core-ai.resolver.js +32 -0
  30. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  31. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
  32. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
  33. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
  34. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
  35. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  36. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
  37. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
  38. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
  39. package/dist/core/modules/ai/index.d.ts +7 -0
  40. package/dist/core/modules/ai/index.js +6 -0
  41. package/dist/core/modules/ai/index.js.map +1 -1
  42. package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
  43. package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
  44. package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
  45. package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
  46. package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
  47. package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
  48. package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
  49. package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
  50. package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
  51. package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
  52. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
  53. package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
  54. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  55. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
  56. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  57. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
  58. package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
  59. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  60. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
  61. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  62. package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
  63. package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
  64. package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
  65. package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
  66. package/dist/core/modules/ai/services/core-ai.service.js +68 -29
  67. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  68. package/dist/core/modules/better-auth/better-auth.config.js +3 -1
  69. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  70. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
  71. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  72. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
  73. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
  74. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
  75. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
  76. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  77. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  78. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
  79. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  80. package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
  81. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  82. package/dist/core/modules/better-auth/index.d.ts +1 -0
  83. package/dist/core/modules/better-auth/index.js +1 -0
  84. package/dist/core/modules/better-auth/index.js.map +1 -1
  85. package/dist/core/modules/error-code/error-codes.d.ts +18 -0
  86. package/dist/core/modules/error-code/error-codes.js +16 -0
  87. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  88. package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
  89. package/dist/core/modules/sync/core-sync.service.js +31 -2
  90. package/dist/core/modules/sync/core-sync.service.js.map +1 -1
  91. package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
  92. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  93. package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
  94. package/dist/core/modules/tenant/core-tenant.service.js +153 -1
  95. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  96. package/dist/server/modules/error-code/error-codes.d.ts +2 -0
  97. package/dist/test/test.helper.d.ts +1 -0
  98. package/dist/test/test.helper.js +9 -2
  99. package/dist/test/test.helper.js.map +1 -1
  100. package/docs/REQUEST-LIFECYCLE.md +3 -2
  101. package/docs/error-codes.md +14 -0
  102. package/docs/offline-sync-protocol.md +17 -8
  103. package/migration-guides/12.0.x-to-12.1.x.md +0 -1
  104. package/migration-guides/12.2.x-to-12.3.x.md +440 -0
  105. package/package.json +20 -20
  106. package/src/config.env.ts +6 -2
  107. package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
  108. package/src/core/common/interfaces/server-options.interface.ts +80 -8
  109. package/src/core/common/persistence/sync-model.ts +1 -1
  110. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
  111. package/src/core/modules/ai/README.md +163 -25
  112. package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
  113. package/src/core/modules/ai/core-ai.constants.ts +6 -0
  114. package/src/core/modules/ai/core-ai.controller.ts +36 -1
  115. package/src/core/modules/ai/core-ai.module.ts +12 -0
  116. package/src/core/modules/ai/core-ai.resolver.ts +36 -0
  117. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
  118. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
  119. package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
  120. package/src/core/modules/ai/index.ts +18 -0
  121. package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
  122. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
  123. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
  124. package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
  125. package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
  126. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
  127. package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
  128. package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
  129. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
  130. package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
  131. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
  132. package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
  133. package/src/core/modules/ai/services/core-ai.service.ts +134 -35
  134. package/src/core/modules/api-token/README.md +4 -2
  135. package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
  136. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
  137. package/src/core/modules/better-auth/README.md +32 -1
  138. package/src/core/modules/better-auth/better-auth.config.ts +12 -1
  139. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
  140. package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
  141. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
  142. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
  143. package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
  144. package/src/core/modules/better-auth/index.ts +1 -0
  145. package/src/core/modules/error-code/error-codes.ts +18 -0
  146. package/src/core/modules/sync/README.md +11 -1
  147. package/src/core/modules/sync/core-sync.service.ts +48 -3
  148. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
  149. package/src/core/modules/tenant/README.md +49 -3
  150. package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
  151. package/src/core/modules/tenant/core-tenant.service.ts +287 -2
  152. package/src/test/test.helper.ts +17 -3
@@ -1,12 +1,30 @@
1
- import { Injectable, Logger } from '@nestjs/common';
1
+ import { Injectable, Logger, Optional } from '@nestjs/common';
2
2
 
3
- import { AiToolContext } from '../interfaces/ai-tool.interface.js';
3
+ import { RequestContext } from '../../../common/services/request-context.service.js';
4
+ import {
5
+ authorizeToolCall,
6
+ effectiveMcpConfirmation,
7
+ mcpConfirmationPolicy,
8
+ toolRequiresConfirmation,
9
+ } from '../helpers/ai-tool-authorization.helper.js';
10
+ import { AiToolContext, IAiTool } from '../interfaces/ai-tool.interface.js';
4
11
  import { AiToolRegistry, AiToolUser } from '../tools/ai-tool.registry.js';
12
+ import type { AiToolPolicyDecision } from './core-ai-tool-policy.service.js';
13
+ import { CoreAiToolPolicyService } from './core-ai-tool-policy.service.js';
14
+ import { CoreAiUserSettingService } from './core-ai-user-setting.service.js';
5
15
 
6
16
  /**
7
- * MCP tool descriptor (name + description + JSON-schema input).
17
+ * MCP tool descriptor (name + description + JSON-schema input + behaviour hints).
8
18
  */
9
19
  export interface McpToolDescriptor {
20
+ /**
21
+ * MCP tool annotations for a tool that declares what it does: `destructive` → `destructiveHint: true`,
22
+ * `mutating` → `destructiveHint: false`, both with `readOnlyHint: false`. Absent for a tool with neither flag:
23
+ * such a tool may still write (the flags used to matter for the chat only), so it is never advertised as
24
+ * read-only and clients keep the spec's cautious defaults. Hints for the client's own confirmation UI, never a
25
+ * security boundary — the server enforces `authorize()` and its confirmation gate itself.
26
+ */
27
+ annotations?: { destructiveHint: boolean; readOnlyHint: boolean };
10
28
  description: string;
11
29
  inputSchema: Record<string, any>;
12
30
  name: string;
@@ -20,6 +38,30 @@ export interface McpCallResult {
20
38
  isError?: boolean;
21
39
  }
22
40
 
41
+ /**
42
+ * The question put to the user before a tool that needs confirmation runs over MCP.
43
+ */
44
+ export interface McpConfirmationPrompt {
45
+ /** The arguments the tool will run with. */
46
+ arguments: Record<string, any>;
47
+ /** The text the MCP client shows the user. */
48
+ message: string;
49
+ /** The tool's name. */
50
+ tool: string;
51
+ }
52
+
53
+ /**
54
+ * Options of {@link CoreAiMcpService.mcpCallTool}.
55
+ */
56
+ export interface McpCallToolOptions {
57
+ /**
58
+ * Asks the user to confirm the call; resolves `true` only when they did. `createServer()` passes one that uses MCP
59
+ * elicitation when the client supports it. Absent: the client cannot ask, so a call that needs confirmation is
60
+ * refused.
61
+ */
62
+ confirm?: (prompt: McpConfirmationPrompt) => Promise<boolean>;
63
+ }
64
+
23
65
  /**
24
66
  * Exposes the {@link AiToolRegistry} as a Model Context Protocol (MCP) server so
25
67
  * external MCP clients (e.g. desktop MCP clients) can use the same backend tools with
@@ -29,6 +71,11 @@ export interface McpCallResult {
29
71
  * `tools/list` and `tools/call` are filtered to that user's permitted tools and
30
72
  * executed with their permissions (via each tool's `execute()` → CrudService).
31
73
  *
74
+ * Since 12.3.0 `tools/call` also applies the gates of the chat orchestrator: the admin's tool policies (`deny`
75
+ * refuses, `ask` asks), the tool's `authorize()`, and the confirmation of a `destructive` tool (or a `mutating` one
76
+ * under `ai.confirmation.mutating`) through MCP elicitation. Whether MCP asks is `ai.mcp.confirmation` (on by default),
77
+ * which each user may switch for themselves (`PUT /ai/settings`) unless the server enforces it.
78
+ *
32
79
  * The `@modelcontextprotocol/sdk` is imported lazily in {@link createServer} so it
33
80
  * is only loaded when MCP is actually enabled — keeping the core lean.
34
81
  */
@@ -36,40 +83,75 @@ export interface McpCallResult {
36
83
  export class CoreAiMcpService {
37
84
  protected readonly logger = new Logger(CoreAiMcpService.name);
38
85
 
39
- constructor(protected readonly toolRegistry: AiToolRegistry) {}
86
+ /** How long the server waits for the user's answer to a confirmation request. */
87
+ protected readonly confirmationTimeoutMs = 5 * 60_000;
88
+
89
+ constructor(
90
+ protected readonly toolRegistry: AiToolRegistry,
91
+ @Optional() protected readonly userSettingService?: CoreAiUserSettingService,
92
+ @Optional() protected readonly toolPolicyService?: CoreAiToolPolicyService,
93
+ ) {}
40
94
 
41
95
  /**
42
96
  * List the MCP tool descriptors the user is allowed to use.
43
97
  */
44
98
  mcpListTools(user: AiToolUser | null | undefined): McpToolDescriptor[] {
45
- return this.toolRegistry
46
- .forUser(user)
47
- .map((tool) => ({ description: tool.description, inputSchema: tool.parameters, name: tool.name }));
99
+ return this.toolRegistry.forUser(user).map((tool) => ({
100
+ ...(tool.destructive || tool.mutating
101
+ ? { annotations: { destructiveHint: tool.destructive === true, readOnlyHint: false } }
102
+ : {}),
103
+ description: tool.description,
104
+ inputSchema: tool.parameters,
105
+ name: tool.name,
106
+ }));
48
107
  }
49
108
 
50
109
  /**
51
110
  * Execute an MCP tool call with the user's permissions. Returns an MCP content
52
- * result; unknown/forbidden tools and execution errors yield `isError: true`.
111
+ * result; unknown/forbidden tools, a refused `authorize()`, a missing confirmation and
112
+ * execution errors yield `isError: true`.
53
113
  */
54
114
  async mcpCallTool(
55
115
  user: AiToolUser | null | undefined,
56
116
  name: string,
57
117
  args: Record<string, any>,
118
+ options: McpCallToolOptions = {},
58
119
  ): Promise<McpCallResult> {
59
120
  const tool = this.toolRegistry.forUser(user).find((t) => t.name === name);
60
121
  if (!tool) {
61
- return { content: [{ text: `Unknown or not permitted tool: ${name}`, type: 'text' }], isError: true };
122
+ return this.errorResult(`Unknown or not permitted tool: ${name}`);
62
123
  }
124
+ const callArgs = args ?? {};
63
125
  const context: AiToolContext = {
64
126
  currentUser: (user ?? undefined) as AiToolContext['currentUser'],
65
127
  serviceOptions: { currentUser: (user ?? undefined) as AiToolContext['currentUser'] },
66
128
  };
129
+
130
+ const policy = await this.evaluateToolPolicy(tool, callArgs, user);
131
+ if (policy?.decision === 'deny') {
132
+ return this.errorResult(policy.reason || `"${name}" is not permitted by policy.`);
133
+ }
134
+ const authorization = await authorizeToolCall(tool, callArgs, context);
135
+ if (!authorization.allowed) {
136
+ return this.errorResult(authorization.reason || `Not permitted: ${name}`);
137
+ }
138
+ if (policy?.decision === 'ask' || (await this.confirmationRequired(tool, user))) {
139
+ if (!options.confirm) {
140
+ return this.errorResult(
141
+ `"${name}" needs the user's confirmation, and this MCP client cannot ask for it (elicitation). Nothing was run.`,
142
+ );
143
+ }
144
+ if (!(await this.askForConfirmation(tool, callArgs, options.confirm))) {
145
+ return this.errorResult(`"${name}" was not run: the user did not confirm it.`);
146
+ }
147
+ }
148
+
67
149
  try {
68
- const result = await tool.execute(args ?? {}, context);
150
+ const result = await tool.execute(callArgs, tool.authorize ? { ...context, authorization } : context);
69
151
  return { content: [{ text: JSON.stringify(result), type: 'text' }] };
70
152
  } catch (err) {
71
153
  this.logger.warn(`MCP tool "${name}" failed: ${(err as Error).message}`);
72
- return { content: [{ text: (err as Error).message, type: 'text' }], isError: true };
154
+ return this.errorResult((err as Error).message);
73
155
  }
74
156
  }
75
157
 
@@ -91,10 +173,121 @@ export class CoreAiMcpService {
91
173
  const server: any = new Server({ name: 'lt-nest-server-ai', version: '1.0.0' }, { capabilities: { tools: {} } });
92
174
 
93
175
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.mcpListTools(currentUser()) }));
94
- server.setRequestHandler(CallToolRequestSchema, async (request: any) =>
95
- this.mcpCallTool(currentUser(), request.params.name, request.params.arguments ?? {}),
176
+ server.setRequestHandler(CallToolRequestSchema, async (request: any, extra: any) =>
177
+ this.mcpCallTool(currentUser(), request.params.name, request.params.arguments ?? {}, {
178
+ // The SDK reads an empty `elicitation: {}` as form elicitation, the kind a yes/no question needs.
179
+ confirm: server.getClientCapabilities()?.elicitation?.form
180
+ ? (prompt: McpConfirmationPrompt) => this.elicitConfirmation(server, prompt, extra?.requestId)
181
+ : undefined,
182
+ }),
96
183
  );
97
184
 
98
185
  return server;
99
186
  }
187
+
188
+ /**
189
+ * Whether this call must be confirmed by the user: a tool that needs confirmation ({@link toolRequiresConfirmation},
190
+ * with `ai.confirmation.mutating`'s default for `mutating`), while MCP asks this user — `ai.mcp.confirmation`, or the
191
+ * user's own choice where the server allows one. An admin's `ask` policy is decided before and asks regardless.
192
+ */
193
+ protected async confirmationRequired(tool: IAiTool, user: AiToolUser | null | undefined): Promise<boolean> {
194
+ if (!toolRequiresConfirmation(tool)) {
195
+ return false;
196
+ }
197
+ if (this.userSettingService) {
198
+ return this.userSettingService.mcpConfirmationFor(user?.id);
199
+ }
200
+ return effectiveMcpConfirmation(mcpConfirmationPolicy());
201
+ }
202
+
203
+ /**
204
+ * The admin's tool policy for this call (`aiToolPolicies`), as the chat orchestrator evaluates it. `undefined`
205
+ * without the policy service, without a matching rule, or when the evaluation fails (logged).
206
+ */
207
+ protected async evaluateToolPolicy(
208
+ tool: IAiTool,
209
+ args: Record<string, any>,
210
+ user: AiToolUser | null | undefined,
211
+ ): Promise<AiToolPolicyDecision | undefined> {
212
+ if (!this.toolPolicyService) {
213
+ return undefined;
214
+ }
215
+ try {
216
+ return await this.toolPolicyService.evaluate(tool.name, args, {
217
+ roles: (user?.roles as string[]) ?? [],
218
+ tenantId: RequestContext.getTenantId(),
219
+ userId: user?.id,
220
+ });
221
+ } catch (err) {
222
+ this.logger.warn(`AI tool-policy evaluation for "${tool.name}" failed: ${(err as Error).message}`);
223
+ return undefined;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * The question shown to the user. English, because an MCP request carries no language; override to localize.
229
+ *
230
+ * Every argument gets a line of its own and only its value is shortened: the arguments come from the model, and
231
+ * a cut over the whole text would let a long first value push the one that decides the outcome (the target id)
232
+ * out of the question the user approves.
233
+ */
234
+ protected confirmationPrompt(tool: IAiTool, args: Record<string, any>): McpConfirmationPrompt {
235
+ const maxValueLength = 200;
236
+ const lines = Object.entries(args).map(([key, value]) => {
237
+ const text = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
238
+ const shown =
239
+ text.length > maxValueLength ? `${text.slice(0, maxValueLength)}… (${text.length} characters)` : text;
240
+ return `- ${key}: ${shown}`;
241
+ });
242
+ const consequence = tool.destructive ? 'This cannot be undone.' : 'This changes data.';
243
+ const argumentsText = lines.length ? `\nArguments:\n${lines.join('\n')}` : '';
244
+ return {
245
+ arguments: args,
246
+ message: `Allow the AI assistant to run "${tool.name}"? ${consequence}${argumentsText}`,
247
+ tool: tool.name,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Puts the question to the user. Anything but an explicit yes — a no, a cancelled dialog, an error, a timeout —
253
+ * counts as "not confirmed".
254
+ */
255
+ protected async askForConfirmation(
256
+ tool: IAiTool,
257
+ args: Record<string, any>,
258
+ confirm: NonNullable<McpCallToolOptions['confirm']>,
259
+ ): Promise<boolean> {
260
+ try {
261
+ return (await confirm(this.confirmationPrompt(tool, args))) === true;
262
+ } catch (err) {
263
+ this.logger.warn(`Confirmation of MCP tool "${tool.name}" failed, not run: ${(err as Error).message}`);
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Asks through MCP form elicitation, as part of the running `tools/call` request (`relatedRequestId`), so a
270
+ * Streamable HTTP client receives the question on that request's stream.
271
+ */
272
+ protected async elicitConfirmation(server: any, prompt: McpConfirmationPrompt, requestId: unknown): Promise<boolean> {
273
+ const result = await server.elicitInput(
274
+ {
275
+ message: prompt.message,
276
+ requestedSchema: {
277
+ properties: {
278
+ confirm: { description: `Run "${prompt.tool}" now`, title: 'Confirm', type: 'boolean' },
279
+ },
280
+ required: ['confirm'],
281
+ type: 'object',
282
+ },
283
+ },
284
+ { relatedRequestId: requestId, timeout: this.confirmationTimeoutMs },
285
+ );
286
+ return result?.action === 'accept' && result.content?.confirm === true;
287
+ }
288
+
289
+ /** An MCP error result with one text block. */
290
+ protected errorResult(text: string): McpCallResult {
291
+ return { content: [{ text, type: 'text' }], isError: true };
292
+ }
100
293
  }
@@ -221,12 +221,19 @@ export class CoreAiPromptBuilderService {
221
221
  : '\n\n[Schemas deferred. Call `search_tools` with the tool name to fetch its parameter schema BEFORE you call it.]';
222
222
  }
223
223
  }
224
- const toolCatalog = defer
225
- ? (tools.map((t) => `- ${t.name}: ${this.summarizeToolDescription(t.description, summaryChars)}`).join('\n') ||
226
- '(none)') + deferNote
227
- : tools
228
- .map((t) => `- ${t.name}: ${t.description}\n parameters (JSON schema): ${JSON.stringify(t.parameters)}`)
229
- .join('\n') || '(none)';
224
+ // A native connection receives every description and schema in the tool payload (`buildToolSchemas()`), so its
225
+ // catalog lists names only (12.3.0) — repeating them here paid for each tool twice. Emulated connections and plan
226
+ // mode have no tool payload; their catalog is the model's only source. `ai.nativeToolCatalog: 'full'` restores
227
+ // the full catalog for a model that picks tools better with it.
228
+ const namesOnly = supportsNativeTools && !planMode && ConfigService.get<string>('ai.nativeToolCatalog') !== 'full';
229
+ const toolCatalog = namesOnly
230
+ ? tools.map((t) => `- ${t.name}`).join('\n') || '(none)'
231
+ : defer
232
+ ? (tools.map((t) => `- ${t.name}: ${this.summarizeToolDescription(t.description, summaryChars)}`).join('\n') ||
233
+ '(none)') + deferNote
234
+ : tools
235
+ .map((t) => `- ${t.name}: ${t.description}\n parameters (JSON schema): ${JSON.stringify(t.parameters)}`)
236
+ .join('\n') || '(none)';
230
237
 
231
238
  if (this.placeholderRegistry) {
232
239
  return this.placeholderRegistry.resolveAll({ toolCatalog, tools, user });
@@ -0,0 +1,85 @@
1
+ import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
2
+ import { InjectModel } from '../../../common/persistence/mongo/mongoose-compat.js';
3
+ import type { Model } from 'mongoose';
4
+
5
+ import { upsertRecord } from '../../../common/persistence/store-upsert.js';
6
+ import { CrudService } from '../../../common/services/crud.service.js';
7
+ import type { CoreModelConstructor } from '../../../common/types/core-model-constructor.type.js';
8
+ import { ErrorCode } from '../../error-code/index.js';
9
+ import { effectiveMcpConfirmation, mcpConfirmationPolicy } from '../helpers/ai-tool-authorization.helper.js';
10
+ import type { CoreAiSettingsInput } from '../inputs/core-ai-settings.input.js';
11
+ import { CoreAiSettings } from '../models/core-ai-settings.model.js';
12
+ import { AiUserSettingDocument, CoreAiUserSetting } from '../models/core-ai-user-setting.model.js';
13
+
14
+ import { AI_USER_SETTING_CLASS, AI_USER_SETTING_MODEL } from '../core-ai.constants.js';
15
+
16
+ /**
17
+ * Personal AI settings (12.3.0+): what a user chose for themselves, combined with the server configuration into the
18
+ * settings that take effect. Today that is whether MCP asks the user before a tool that needs confirmation
19
+ * (`ai.mcp.confirmation`) — the user's own "bypass permissions" switch, unless the server enforces it.
20
+ *
21
+ * Reads and writes go through the routed store with the user id the caller passes; the endpoints pass the current
22
+ * user's, so nobody reaches another user's settings.
23
+ *
24
+ * Override via `CoreModule.forRoot(env, { ai: { userSettingService } })`.
25
+ */
26
+ @Injectable()
27
+ export class CoreAiUserSettingService extends CrudService<CoreAiUserSetting, CoreAiUserSetting, CoreAiUserSetting> {
28
+ protected readonly logger = new Logger(CoreAiUserSettingService.name);
29
+
30
+ constructor(
31
+ @InjectModel(AI_USER_SETTING_MODEL) protected override readonly mainDbModel: Model<AiUserSettingDocument>,
32
+ @Inject(AI_USER_SETTING_CLASS)
33
+ protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiUserSetting>,
34
+ ) {
35
+ super();
36
+ }
37
+
38
+ /**
39
+ * The user's settings as they take effect.
40
+ */
41
+ async getSettings(userId: string | undefined): Promise<CoreAiSettings> {
42
+ const policy = mcpConfirmationPolicy();
43
+ const stored = userId ? await this.findForUser(userId) : undefined;
44
+ const settings = new CoreAiSettings();
45
+ settings.mcpConfirmation = effectiveMcpConfirmation(policy, stored?.mcpConfirmation);
46
+ settings.mcpConfirmationEnforced = policy.enforced;
47
+ return settings;
48
+ }
49
+
50
+ /**
51
+ * Stores the user's choices and answers with the settings that take effect. A choice the server enforces is
52
+ * refused with 403 (`AI_SETTING_ENFORCED`) rather than stored and ignored.
53
+ */
54
+ async updateSettings(userId: string, input: CoreAiSettingsInput): Promise<CoreAiSettings> {
55
+ if (typeof input?.mcpConfirmation === 'boolean') {
56
+ if (mcpConfirmationPolicy().enforced) {
57
+ throw new ForbiddenException(ErrorCode.AI_SETTING_ENFORCED);
58
+ }
59
+ await upsertRecord(this.dataStore, { userId }, { mcpConfirmation: input.mcpConfirmation });
60
+ }
61
+ return this.getSettings(userId);
62
+ }
63
+
64
+ /**
65
+ * Whether MCP asks this user before a tool that needs confirmation. When the stored choice cannot be read, it asks
66
+ * — unless the policy is enforced, which needs no stored choice.
67
+ */
68
+ async mcpConfirmationFor(userId: string | undefined): Promise<boolean> {
69
+ const policy = mcpConfirmationPolicy();
70
+ if (policy.enforced || !userId) {
71
+ return policy.default;
72
+ }
73
+ try {
74
+ return effectiveMcpConfirmation(policy, (await this.findForUser(userId))?.mcpConfirmation);
75
+ } catch (err) {
76
+ this.logger.warn(`AI settings of user ${userId} could not be read, MCP asks: ${(err as Error).message}`);
77
+ return true;
78
+ }
79
+ }
80
+
81
+ /** The stored record of one user, if any. */
82
+ protected async findForUser(userId: string): Promise<CoreAiUserSetting | undefined> {
83
+ return ((await this.dataStore.findOne({ userId })) as CoreAiUserSetting | null) ?? undefined;
84
+ }
85
+ }