@nocobase/ai 2.1.9 → 2.1.10

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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { Context } from '@nocobase/actions';
11
+ import { MultiServerMCPClient, StdioConnection, StreamableHTTPConnection } from '@langchain/mcp-adapters';
12
+ import { StructuredToolInterface } from '@langchain/core/tools';
13
+ import type { MCPEntry, MCPOptions } from './types';
14
+ import { renderMCPOptions } from './options-renderer';
15
+
16
+ type MCPConnection = StdioConnection | StreamableHTTPConnection;
17
+
18
+ type CacheEntry = {
19
+ client: MultiServerMCPClient;
20
+ toolsMap: Record<string, StructuredToolInterface[]>;
21
+ expiresAt: number;
22
+ lastAccessedAt: number;
23
+ };
24
+
25
+ export type UserContextMCPClientManagerOptions = {
26
+ app: any;
27
+ listEntries: () => Promise<MCPEntry[]>;
28
+ buildConnection: (options: MCPOptions) => MCPConnection;
29
+ ttlMs?: number;
30
+ maxSize?: number;
31
+ };
32
+
33
+ export class UserContextMCPClientManager {
34
+ private readonly cache = new Map<string, CacheEntry>();
35
+ private readonly ttlMs: number;
36
+ private readonly maxSize: number;
37
+
38
+ constructor(private readonly options: UserContextMCPClientManagerOptions) {
39
+ this.ttlMs = options.ttlMs ?? 5 * 60 * 1000;
40
+ this.maxSize = options.maxSize ?? 100;
41
+ }
42
+
43
+ async getToolsMap(ctx: Context): Promise<Record<string, StructuredToolInterface[]>> {
44
+ const currentUser = ctx?.state?.currentUser ?? ctx?.auth?.user;
45
+ if (!currentUser?.id) {
46
+ return {};
47
+ }
48
+
49
+ this.evictExpired();
50
+
51
+ const entries = (await this.options.listEntries())
52
+ .filter((entry) => entry.enabled !== false && entry.useUserContext === true && entry.transport !== 'stdio')
53
+ .sort((left, right) => left.name.localeCompare(right.name));
54
+ if (!entries.length) {
55
+ return {};
56
+ }
57
+
58
+ const cacheKey = String(currentUser.id);
59
+ const cached = this.cache.get(cacheKey);
60
+ const now = Date.now();
61
+ if (cached && cached.expiresAt > now) {
62
+ cached.lastAccessedAt = now;
63
+ return cached.toolsMap;
64
+ }
65
+
66
+ if (cached) {
67
+ await this.closeEntry(cached);
68
+ this.cache.delete(cacheKey);
69
+ }
70
+
71
+ const connections: Record<string, MCPConnection> = {};
72
+ for (const entry of entries) {
73
+ const rendered = await renderMCPOptions(entry, this.options.app, ctx);
74
+ connections[entry.name] = this.options.buildConnection(rendered);
75
+ }
76
+
77
+ const client = new MultiServerMCPClient(connections);
78
+ const initializedToolsMap = await client.initializeConnections();
79
+ const toolsMap = Object.fromEntries(
80
+ Object.entries(initializedToolsMap).map(([serverName, tools]) => [
81
+ serverName,
82
+ tools as StructuredToolInterface[],
83
+ ]),
84
+ );
85
+
86
+ this.cache.set(cacheKey, {
87
+ client,
88
+ toolsMap,
89
+ expiresAt: now + this.ttlMs,
90
+ lastAccessedAt: now,
91
+ });
92
+ await this.evictOversized();
93
+
94
+ return toolsMap;
95
+ }
96
+
97
+ async clear() {
98
+ const entries = [...this.cache.values()];
99
+ this.cache.clear();
100
+ await Promise.all(entries.map((entry) => this.closeEntry(entry)));
101
+ }
102
+
103
+ private evictExpired() {
104
+ const now = Date.now();
105
+ for (const [key, entry] of this.cache.entries()) {
106
+ if (entry.expiresAt <= now) {
107
+ this.cache.delete(key);
108
+ this.closeEntry(entry).catch((error) => {
109
+ this.options.app?.log?.warn?.('fail to close expired user-bound mcp client', error);
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ private async evictOversized() {
116
+ while (this.cache.size > this.maxSize) {
117
+ const oldest = [...this.cache.entries()].sort(
118
+ ([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt,
119
+ )[0];
120
+ if (!oldest) {
121
+ return;
122
+ }
123
+ this.cache.delete(oldest[0]);
124
+ await this.closeEntry(oldest[1]);
125
+ }
126
+ }
127
+
128
+ private async closeEntry(entry: CacheEntry) {
129
+ try {
130
+ await entry.client.close();
131
+ } catch (error) {
132
+ this.options.app?.log?.warn?.('fail to close user-bound mcp client', error);
133
+ }
134
+ }
135
+ }
@@ -56,4 +56,5 @@ export type ToolsFilter = {
56
56
  defaultPermission?: Permission;
57
57
  silence?: boolean;
58
58
  sessionId?: string;
59
+ ctx?: Context;
59
60
  };