@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.
- package/.claude/rules/better-auth.md +31 -4
- package/.claude/rules/configurable-features.md +7 -6
- package/.claude/rules/package-management.md +13 -1
- package/.claude/rules/persistence.md +2 -2
- package/.claude/rules/testing.md +5 -5
- package/.claude/rules/versioning.md +13 -2
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +4 -3
- package/README.md +2 -2
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.constants.js +2 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.controller.js +31 -1
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +8 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.resolver.js +32 -0
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +7 -0
- package/dist/core/modules/ai/index.js +6 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
- package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
- package/dist/core/modules/ai/services/core-ai.service.js +68 -29
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +3 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +18 -0
- package/dist/core/modules/error-code/error-codes.js +16 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
- package/dist/core/modules/sync/core-sync.service.js +31 -2
- package/dist/core/modules/sync/core-sync.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
- package/dist/core/modules/tenant/core-tenant.service.js +153 -1
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +2 -0
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +9 -2
- package/dist/test/test.helper.js.map +1 -1
- package/docs/REQUEST-LIFECYCLE.md +3 -2
- package/docs/error-codes.md +14 -0
- package/docs/offline-sync-protocol.md +17 -8
- package/migration-guides/12.0.x-to-12.1.x.md +0 -1
- package/migration-guides/12.2.x-to-12.3.x.md +440 -0
- package/package.json +20 -20
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interfaces/server-options.interface.ts +80 -8
- package/src/core/common/persistence/sync-model.ts +1 -1
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
- package/src/core/modules/ai/README.md +163 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +6 -0
- package/src/core/modules/ai/core-ai.controller.ts +36 -1
- package/src/core/modules/ai/core-ai.module.ts +12 -0
- package/src/core/modules/ai/core-ai.resolver.ts +36 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
- package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
- package/src/core/modules/ai/index.ts +18 -0
- package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
- package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
- package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
- package/src/core/modules/ai/services/core-ai.service.ts +134 -35
- package/src/core/modules/api-token/README.md +4 -2
- package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +12 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
- package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +18 -0
- package/src/core/modules/sync/README.md +11 -1
- package/src/core/modules/sync/core-sync.service.ts +48 -3
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
- package/src/core/modules/tenant/README.md +49 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
- package/src/core/modules/tenant/core-tenant.service.ts +287 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
.
|
|
47
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|