@lenne.tech/nest-server 12.2.0 → 12.3.1
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 +8 -7
- package/.claude/rules/package-management.md +18 -2
- 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/interceptors/check-security.interceptor.js +19 -2
- package/dist/core/common/interceptors/check-security.interceptor.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 +3 -0
- package/dist/core/modules/ai/core-ai.constants.js +3 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +9 -0
- package/dist/core/modules/ai/core-ai.controller.js +48 -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 +9 -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 +8 -0
- package/dist/core/modules/ai/index.js +7 -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-status.model.d.ts +3 -0
- package/dist/core/modules/ai/models/core-ai-status.model.js +25 -0
- package/dist/core/modules/ai/models/core-ai-status.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 +10 -4
- 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 +5 -4
- 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/migration-guides/12.3.0-to-12.3.1.md +136 -0
- package/package.json +26 -26
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interceptors/check-security.interceptor.ts +26 -2
- package/src/core/common/interfaces/server-options.interface.ts +84 -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 +178 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +10 -0
- package/src/core/modules/ai/core-ai.controller.ts +55 -1
- package/src/core/modules/ai/core-ai.module.ts +14 -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 +19 -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-status.model.ts +16 -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 +12 -2
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +21 -5
- 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,4 +1,4 @@
|
|
|
1
|
-
import { Inject, Injectable } from '@nestjs/common';
|
|
1
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
2
2
|
import { InjectConnection, InjectModel } from '../../../common/persistence/mongo/mongoose-compat.js';
|
|
3
3
|
import type { Connection, Model } from 'mongoose';
|
|
4
4
|
|
|
@@ -41,6 +41,8 @@ export class CoreAiConversationService extends CrudService<
|
|
|
41
41
|
CoreAiConversationCreateInput,
|
|
42
42
|
CoreAiConversationInput
|
|
43
43
|
> {
|
|
44
|
+
protected readonly logger = new Logger(CoreAiConversationService.name);
|
|
45
|
+
|
|
44
46
|
/** Maximum number of messages retained per conversation (capped on `$push`). */
|
|
45
47
|
protected readonly maxRetainedMessages = 500;
|
|
46
48
|
|
|
@@ -155,12 +157,35 @@ export class CoreAiConversationService extends CrudService<
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* grow unbounded.
|
|
160
|
+
* Whether the conversation exists and was created by `userId`. Admins are not owners: they may READ any
|
|
161
|
+
* conversation (`securityCheck`), but a prompt only ever continues the caller's own one, because its turns
|
|
162
|
+
* become part of that conversation's next runs.
|
|
162
163
|
*/
|
|
163
|
-
async
|
|
164
|
+
async isOwnedBy(id: string, userId: string | undefined): Promise<boolean> {
|
|
165
|
+
if (!isWellFormedId(id) || !isWellFormedId(userId)) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return !!(await this.dataStore.findOne({ createdBy: userId, id }, { select: ['id'] }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Append a message to the conversation of `ownerId` via `$push` (system-internal; the user already authorized
|
|
173
|
+
* the prompt that produced it). Writes nothing unless `ownerId` created the conversation: a conversation's turns
|
|
174
|
+
* feed its owner's next runs, in which tools act with the owner's rights, so a write on anybody else's behalf
|
|
175
|
+
* would plant prompt input into another user's assistant. The check sits here rather than in each caller so that
|
|
176
|
+
* no caller can leave it out. `createdBy` is never changed after creation, so nothing can happen between the
|
|
177
|
+
* check and the push that would make the write foreign. The array is capped at {@link maxRetainedMessages} via
|
|
178
|
+
* `$slice` so a long-lived conversation cannot grow unbounded.
|
|
179
|
+
*/
|
|
180
|
+
async appendMessage(
|
|
181
|
+
id: string,
|
|
182
|
+
message: { content: string; createdAt?: Date; role: string },
|
|
183
|
+
ownerId: string | undefined,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
if (!(await this.isOwnedBy(id, ownerId))) {
|
|
186
|
+
this.logger.warn(`Message not appended: conversation ${id} is not owned by user ${ownerId ?? '(none)'}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
164
189
|
await this.dataStore.pushToArray(id, 'messages', [{ ...message, createdAt: message.createdAt ?? new Date() }], {
|
|
165
190
|
slice: -this.maxRetainedMessages,
|
|
166
191
|
});
|
|
@@ -597,8 +597,10 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
|
|
|
597
597
|
* - signed in, GET → the consent view: which client, where the code goes, Allow / Deny, with a
|
|
598
598
|
* CSRF token bound to user, client, redirect URI and PKCE challenge.
|
|
599
599
|
* - signed in, POST with Allow and a valid token → the code is issued, the browser goes to
|
|
600
|
-
* `redirect_uri?code=…&state=…`. Deny → `access_denied`. A stale token shows the
|
|
601
|
-
* foreign or tampered one too, and
|
|
600
|
+
* `redirect_uri?code=…&state=…`. Deny with a valid token → `access_denied`. A stale token shows the
|
|
601
|
+
* form again; a foreign or tampered one too, and decides nothing.
|
|
602
|
+
* - an error before this method runs (a malformed request) is answered with 400, never sent to the
|
|
603
|
+
* client's redirect URI — `mountAiMcpOAuthRouter()` guards `/authorize` (RFC 9700 §4.11.2).
|
|
602
604
|
*
|
|
603
605
|
* The signed-in user is whoever the Better-Auth middleware put on the request (session cookie or
|
|
604
606
|
* bearer). An API token never gets here — `buildOAuthProvider().authorize` refuses it first — and a
|
|
@@ -615,20 +617,21 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
|
|
|
615
617
|
const user = await this.consentUser(req);
|
|
616
618
|
const decision = req?.method === 'POST' ? req.body?.[MCP_OAUTH_CONSENT_DECISION_FIELD] : undefined;
|
|
617
619
|
|
|
618
|
-
if (user && decision === 'deny') {
|
|
619
|
-
const { AccessDeniedError } = await this.loadOAuthErrors();
|
|
620
|
-
throw new AccessDeniedError('The user denied access');
|
|
621
|
-
}
|
|
622
|
-
|
|
623
620
|
let notice: IMcpOAuthConsentView['notice'];
|
|
624
|
-
if (user && decision === 'approve') {
|
|
621
|
+
if (user && (decision === 'approve' || decision === 'deny')) {
|
|
625
622
|
const binding: IAiMcpOAuthConsentBinding = {
|
|
626
623
|
clientId: String(client.client_id),
|
|
627
624
|
codeChallenge: String(params.codeChallenge),
|
|
628
625
|
redirectUri: String(params.redirectUri),
|
|
629
626
|
userId: user.id,
|
|
630
627
|
};
|
|
628
|
+
// Deny needs the token as Allow does: it sends the browser to the client's redirect URI, and without the token a
|
|
629
|
+
// cross-site POST could do that for a signed-in user whose session cookie is `SameSite=None`
|
|
631
630
|
const verdict = this.verifyConsentToken(req.body?.[MCP_OAUTH_CONSENT_TOKEN_FIELD], binding);
|
|
631
|
+
if (verdict === 'valid' && decision === 'deny') {
|
|
632
|
+
const { AccessDeniedError } = await this.loadOAuthErrors();
|
|
633
|
+
throw new AccessDeniedError('The user denied access');
|
|
634
|
+
}
|
|
632
635
|
if (verdict === 'valid') {
|
|
633
636
|
const code = this.generateId();
|
|
634
637
|
await this.saveAuthorizationCode(code, {
|
|
@@ -649,7 +652,7 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
|
|
|
649
652
|
} else {
|
|
650
653
|
this.logger.warn(
|
|
651
654
|
`MCP OAuth consent for client ${binding.clientId} carried an invalid token (forged, tampered, or from ` +
|
|
652
|
-
'another session);
|
|
655
|
+
'another session); neither approved nor denied, the form is shown again.',
|
|
653
656
|
);
|
|
654
657
|
}
|
|
655
658
|
}
|
|
@@ -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
|
+
}
|