@nocobase/ai 2.2.0-alpha.1 → 2.2.0-alpha.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,33 @@
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
+ import type { Context } from '@nocobase/actions';
10
+ import { StdioConnection, StreamableHTTPConnection } from '@langchain/mcp-adapters';
11
+ import { StructuredToolInterface } from '@langchain/core/tools';
12
+ import type { MCPEntry, MCPOptions } from './types';
13
+ type MCPConnection = StdioConnection | StreamableHTTPConnection;
14
+ export type UserContextMCPClientManagerOptions = {
15
+ app: any;
16
+ listEntries: () => Promise<MCPEntry[]>;
17
+ buildConnection: (options: MCPOptions) => MCPConnection;
18
+ ttlMs?: number;
19
+ maxSize?: number;
20
+ };
21
+ export declare class UserContextMCPClientManager {
22
+ private readonly options;
23
+ private readonly cache;
24
+ private readonly ttlMs;
25
+ private readonly maxSize;
26
+ constructor(options: UserContextMCPClientManagerOptions);
27
+ getToolsMap(ctx: Context): Promise<Record<string, StructuredToolInterface[]>>;
28
+ clear(): Promise<void>;
29
+ private evictExpired;
30
+ private evictOversized;
31
+ private closeEntry;
32
+ }
33
+ export {};
@@ -0,0 +1,146 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var user_context_client_manager_exports = {};
29
+ __export(user_context_client_manager_exports, {
30
+ UserContextMCPClientManager: () => UserContextMCPClientManager
31
+ });
32
+ module.exports = __toCommonJS(user_context_client_manager_exports);
33
+ var import_mcp_adapters = require("@langchain/mcp-adapters");
34
+ var import_options_renderer = require("./options-renderer");
35
+ const _UserContextMCPClientManager = class _UserContextMCPClientManager {
36
+ constructor(options) {
37
+ this.options = options;
38
+ this.ttlMs = options.ttlMs ?? 5 * 60 * 1e3;
39
+ this.maxSize = options.maxSize ?? 100;
40
+ }
41
+ cache = /* @__PURE__ */ new Map();
42
+ ttlMs;
43
+ maxSize;
44
+ async getToolsMap(ctx) {
45
+ var _a, _b, _c, _d, _e;
46
+ const currentUser = ((_a = ctx == null ? void 0 : ctx.state) == null ? void 0 : _a.currentUser) ?? ((_b = ctx == null ? void 0 : ctx.auth) == null ? void 0 : _b.user);
47
+ if (!(currentUser == null ? void 0 : currentUser.id)) {
48
+ return {};
49
+ }
50
+ let client = null;
51
+ try {
52
+ this.evictExpired();
53
+ const entries = (await this.options.listEntries()).filter((entry) => entry.enabled !== false && entry.useUserContext === true && entry.transport !== "stdio").sort((left, right) => left.name.localeCompare(right.name));
54
+ if (!entries.length) {
55
+ return {};
56
+ }
57
+ const cacheKey = String(currentUser.id);
58
+ const cached = this.cache.get(cacheKey);
59
+ const now = Date.now();
60
+ if (cached && cached.expiresAt > now) {
61
+ cached.lastAccessedAt = now;
62
+ return cached.toolsMap;
63
+ }
64
+ if (cached) {
65
+ await this.closeEntry(cached);
66
+ this.cache.delete(cacheKey);
67
+ }
68
+ const connections = {};
69
+ for (const entry of entries) {
70
+ const rendered = await (0, import_options_renderer.renderMCPOptions)(entry, this.options.app, ctx);
71
+ connections[entry.name] = this.options.buildConnection(rendered);
72
+ }
73
+ client = new import_mcp_adapters.MultiServerMCPClient(connections);
74
+ const initializedToolsMap = await client.initializeConnections();
75
+ const toolsMap = Object.fromEntries(
76
+ Object.entries(initializedToolsMap).map(([serverName, tools]) => [
77
+ serverName,
78
+ tools
79
+ ])
80
+ );
81
+ this.cache.set(cacheKey, {
82
+ client,
83
+ toolsMap,
84
+ expiresAt: now + this.ttlMs,
85
+ lastAccessedAt: now
86
+ });
87
+ client = null;
88
+ await this.evictOversized();
89
+ return toolsMap;
90
+ } catch (error) {
91
+ if (client) {
92
+ await this.closeEntry({
93
+ client,
94
+ toolsMap: {},
95
+ expiresAt: 0,
96
+ lastAccessedAt: 0
97
+ });
98
+ }
99
+ (_e = (_d = (_c = this.options.app) == null ? void 0 : _c.log) == null ? void 0 : _d.warn) == null ? void 0 : _e.call(_d, "fail to get user-bound mcp tools", error);
100
+ return {};
101
+ }
102
+ }
103
+ async clear() {
104
+ const entries = [...this.cache.values()];
105
+ this.cache.clear();
106
+ await Promise.all(entries.map((entry) => this.closeEntry(entry)));
107
+ }
108
+ evictExpired() {
109
+ const now = Date.now();
110
+ for (const [key, entry] of this.cache.entries()) {
111
+ if (entry.expiresAt <= now) {
112
+ this.cache.delete(key);
113
+ this.closeEntry(entry).catch((error) => {
114
+ var _a, _b, _c;
115
+ (_c = (_b = (_a = this.options.app) == null ? void 0 : _a.log) == null ? void 0 : _b.warn) == null ? void 0 : _c.call(_b, "fail to close expired user-bound mcp client", error);
116
+ });
117
+ }
118
+ }
119
+ }
120
+ async evictOversized() {
121
+ while (this.cache.size > this.maxSize) {
122
+ const oldest = [...this.cache.entries()].sort(
123
+ ([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt
124
+ )[0];
125
+ if (!oldest) {
126
+ return;
127
+ }
128
+ this.cache.delete(oldest[0]);
129
+ await this.closeEntry(oldest[1]);
130
+ }
131
+ }
132
+ async closeEntry(entry) {
133
+ var _a, _b, _c;
134
+ try {
135
+ await entry.client.close();
136
+ } catch (error) {
137
+ (_c = (_b = (_a = this.options.app) == null ? void 0 : _a.log) == null ? void 0 : _b.warn) == null ? void 0 : _c.call(_b, "fail to close user-bound mcp client", error);
138
+ }
139
+ }
140
+ };
141
+ __name(_UserContextMCPClientManager, "UserContextMCPClientManager");
142
+ let UserContextMCPClientManager = _UserContextMCPClientManager;
143
+ // Annotate the CommonJS export names for ESM import in node:
144
+ 0 && (module.exports = {
145
+ UserContextMCPClientManager
146
+ });
@@ -47,4 +47,5 @@ export type ToolsFilter = {
47
47
  defaultPermission?: Permission;
48
48
  silence?: boolean;
49
49
  sessionId?: string;
50
+ ctx?: Context;
50
51
  };
package/package.json CHANGED
@@ -1,31 +1,31 @@
1
1
  {
2
2
  "name": "@nocobase/ai",
3
- "version": "2.2.0-alpha.1",
3
+ "version": "2.2.0-alpha.10",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
8
8
  "dependencies": {
9
- "@langchain/anthropic": "^1.3.17",
10
- "@langchain/classic": "^1.0.21",
11
- "@langchain/community": "~1.1.18",
12
- "@langchain/core": "~1.1.27",
13
- "@langchain/deepseek": "^1.0.11",
14
- "@langchain/google-genai": "^2.1.18",
15
- "@langchain/langgraph": "~1.1.4",
16
- "@langchain/langgraph-checkpoint": "^1.0.0",
17
- "@langchain/mcp-adapters": "^1.1.3",
18
- "@langchain/ollama": "~1.2.7",
19
- "@langchain/openai": "^1.2.7",
20
- "@nocobase/data-source-manager": "2.2.0-alpha.1",
21
- "@nocobase/logger": "2.2.0-alpha.1",
22
- "@nocobase/resourcer": "2.2.0-alpha.1",
23
- "@nocobase/utils": "2.2.0-alpha.1",
9
+ "@langchain/anthropic": "1.3.17",
10
+ "@langchain/classic": "1.0.34",
11
+ "@langchain/community": "1.1.29",
12
+ "@langchain/core": "1.1.49",
13
+ "@langchain/deepseek": "1.0.27",
14
+ "@langchain/google-genai": "2.1.18",
15
+ "@langchain/langgraph": "1.4.4",
16
+ "@langchain/langgraph-checkpoint": "1.1.2",
17
+ "@langchain/mcp-adapters": "1.1.3",
18
+ "@langchain/ollama": "1.2.7",
19
+ "@langchain/openai": "1.4.7",
20
+ "@nocobase/data-source-manager": "2.2.0-alpha.10",
21
+ "@nocobase/logger": "2.2.0-alpha.10",
22
+ "@nocobase/resourcer": "2.2.0-alpha.10",
23
+ "@nocobase/utils": "2.2.0-alpha.10",
24
24
  "d3-dsv": "2",
25
25
  "fast-glob": "^3.3.2",
26
26
  "flexsearch": "^0.8.2",
27
27
  "gray-matter": "^4.0.3",
28
- "langchain": "~1.2.24",
28
+ "langchain": "1.2.39",
29
29
  "mammoth": "^1.10.0",
30
30
  "officeparser": "^5.2.0",
31
31
  "pdf-parse": "^1.1.1",
@@ -37,5 +37,5 @@
37
37
  "url": "git+https://github.com/nocobase/nocobase.git",
38
38
  "directory": "packages/ai"
39
39
  },
40
- "gitHead": "303663aba6c6eefa27e6a6435b4c0352074ec40f"
40
+ "gitHead": "b5a3f131bb0a4a2fbfd96852896bccadc933d834"
41
41
  }
@@ -0,0 +1,63 @@
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 { loadByWorker } from '../document-loader';
11
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import * as XLSX from 'xlsx';
15
+
16
+ describe('Document loader worker', () => {
17
+ const tempDirs: string[] = [];
18
+
19
+ const createTempFile = async (filename: string, content: string | Buffer) => {
20
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-document-loader-test-'));
21
+ tempDirs.push(tempDir);
22
+ const filePath = path.join(tempDir, filename);
23
+ await writeFile(filePath, content);
24
+ return filePath;
25
+ };
26
+
27
+ afterEach(async () => {
28
+ await Promise.all(tempDirs.splice(0).map((tempDir) => rm(tempDir, { recursive: true, force: true })));
29
+ });
30
+
31
+ it('loads text files by path', async () => {
32
+ const filePath = await createTempFile('source.txt', 'hello knowledge base\nsecond line\n');
33
+
34
+ const documents = await loadByWorker('.txt', {
35
+ filePath,
36
+ mimeType: 'text/plain',
37
+ });
38
+
39
+ expect(documents).toHaveLength(1);
40
+ expect(documents[0].pageContent).toBe('hello knowledge base\nsecond line\n');
41
+ expect(documents[0].metadata.source).toBe(filePath);
42
+ });
43
+
44
+ it('loads xlsx files by path', async () => {
45
+ const worksheet = XLSX.utils.aoa_to_sheet([
46
+ ['name', 'value'],
47
+ ['alpha', 1],
48
+ ]);
49
+ const workbook = XLSX.utils.book_new();
50
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
51
+ const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
52
+ const filePath = await createTempFile('source.xlsx', buffer);
53
+
54
+ const documents = await loadByWorker('.xlsx', {
55
+ filePath,
56
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
57
+ });
58
+
59
+ expect(documents).toHaveLength(1);
60
+ expect(documents[0].pageContent).toBe('Sheet: Sheet1\nname\tvalue\nalpha\t1');
61
+ expect(documents[0].metadata.source).toBe(filePath);
62
+ });
63
+ });
@@ -0,0 +1,339 @@
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
+ const mcpClientMock = vi.hoisted(() => {
11
+ const instances: any[] = [];
12
+
13
+ class MultiServerMCPClient {
14
+ connections: Record<string, any>;
15
+ close = vi.fn();
16
+
17
+ constructor(connections: Record<string, any>) {
18
+ this.connections = connections;
19
+ instances.push(this);
20
+ }
21
+
22
+ async initializeConnections() {
23
+ if (Object.values(this.connections).some((connection) => connection?.failInitialize)) {
24
+ throw new Error('initialize failed');
25
+ }
26
+ return Object.fromEntries(
27
+ Object.keys(this.connections).map((serverName) => [
28
+ serverName,
29
+ [
30
+ {
31
+ name: 'getProfile',
32
+ description: `Get profile from ${serverName}`,
33
+ schema: {},
34
+ invoke: vi.fn(async (args) => ({ serverName, args })),
35
+ },
36
+ ],
37
+ ]),
38
+ );
39
+ }
40
+ }
41
+
42
+ return {
43
+ instances,
44
+ MultiServerMCPClient,
45
+ };
46
+ });
47
+
48
+ vi.mock('@langchain/mcp-adapters', () => ({
49
+ MultiServerMCPClient: mcpClientMock.MultiServerMCPClient,
50
+ }));
51
+
52
+ import { DefaultMCPManager } from '../mcp-manager';
53
+ import { normalizeMCPOptions, renderMCPOptions } from '../mcp-manager/options-renderer';
54
+ import { UserContextMCPClientManager } from '../mcp-manager/user-context-client-manager';
55
+
56
+ describe('user-bound MCP clients', () => {
57
+ const createApp = () => ({
58
+ environment: {
59
+ getVariables: () => ({
60
+ MCP_HOST: 'mcp.example.test',
61
+ API_TOKEN: 'env-token',
62
+ }),
63
+ },
64
+ log: {
65
+ warn: vi.fn(),
66
+ },
67
+ });
68
+
69
+ const createCtx = (id: number, extraUser: Record<string, unknown> = {}) =>
70
+ ({
71
+ state: {
72
+ currentUser: {
73
+ id,
74
+ name: `user-${id}`,
75
+ ...extraUser,
76
+ },
77
+ },
78
+ auth: {
79
+ user: {
80
+ id,
81
+ name: `user-${id}`,
82
+ ...extraUser,
83
+ },
84
+ },
85
+ db: {
86
+ getRepository: () => ({
87
+ findOne: vi.fn(),
88
+ }),
89
+ },
90
+ request: {
91
+ headers: {
92
+ authorization: 'Bearer request-token',
93
+ 'x-role': 'admin',
94
+ },
95
+ },
96
+ getBearerToken: () => 'request-token',
97
+ }) as any;
98
+
99
+ beforeEach(() => {
100
+ mcpClientMock.instances.length = 0;
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.useRealTimers();
105
+ });
106
+
107
+ it('normalizes stdio records as not user-bound', () => {
108
+ expect(
109
+ normalizeMCPOptions({
110
+ transport: 'stdio',
111
+ command: 'node',
112
+ args: ['server.js'],
113
+ env: { TOKEN: '{{ $env.API_TOKEN }}' },
114
+ useUserContext: true,
115
+ }),
116
+ ).toMatchObject({
117
+ transport: 'stdio',
118
+ command: 'node',
119
+ args: ['server.js'],
120
+ env: { TOKEN: '{{ $env.API_TOKEN }}' },
121
+ useUserContext: false,
122
+ headers: {},
123
+ });
124
+ });
125
+
126
+ it('renders environment and current user variables in MCP options', async () => {
127
+ const rendered = await renderMCPOptions(
128
+ {
129
+ transport: 'http',
130
+ url: 'https://{{ $env.MCP_HOST }}/users/{{ currentUser.id }}',
131
+ headers: {
132
+ Authorization: 'Bearer {{ $env.API_TOKEN }}',
133
+ 'X-User': '{{ $user.name }}',
134
+ },
135
+ useUserContext: true,
136
+ },
137
+ createApp(),
138
+ createCtx(7),
139
+ );
140
+
141
+ expect(rendered).toMatchObject({
142
+ transport: 'http',
143
+ url: 'https://mcp.example.test/users/7',
144
+ headers: {
145
+ Authorization: 'Bearer env-token',
146
+ 'X-User': 'user-7',
147
+ },
148
+ useUserContext: true,
149
+ });
150
+ });
151
+
152
+ it('renders NocoBase request in MCP options', async () => {
153
+ const rendered = await renderMCPOptions(
154
+ {
155
+ transport: 'http',
156
+ url: 'https://{{ $env.MCP_HOST }}/mcp',
157
+ headers: {
158
+ Authorization: 'Bearer {{ request.token }}',
159
+ 'X-Role': '{{ request.headers.x-role }}',
160
+ },
161
+ useUserContext: true,
162
+ },
163
+ createApp(),
164
+ createCtx(7),
165
+ );
166
+
167
+ expect(rendered).toMatchObject({
168
+ headers: {
169
+ Authorization: 'Bearer request-token',
170
+ 'X-Role': 'admin',
171
+ },
172
+ });
173
+ });
174
+
175
+ it('does not render NocoBase request for shared MCP options', async () => {
176
+ const rendered = await renderMCPOptions(
177
+ {
178
+ transport: 'http',
179
+ url: 'https://{{ $env.MCP_HOST }}/mcp',
180
+ headers: {
181
+ Authorization: 'Bearer {{ request.token }}',
182
+ },
183
+ useUserContext: false,
184
+ },
185
+ createApp(),
186
+ createCtx(7),
187
+ );
188
+
189
+ expect(rendered.headers).toMatchObject({
190
+ Authorization: 'Bearer ',
191
+ });
192
+ });
193
+
194
+ it('excludes user-bound records when rebuilding the shared client', async () => {
195
+ const manager = new DefaultMCPManager(createApp() as any) as any;
196
+ manager.listMCP = vi.fn().mockResolvedValue([]);
197
+
198
+ await manager.rebuildClient();
199
+
200
+ expect(manager.listMCP).toHaveBeenCalledWith({ enabled: true, useUserContext: false });
201
+ expect(mcpClientMock.instances).toHaveLength(0);
202
+ });
203
+
204
+ it('registers user-bound tools from filter ctx', async () => {
205
+ const manager = new DefaultMCPManager(createApp() as any) as any;
206
+ manager.listMCP = vi.fn().mockResolvedValue([
207
+ {
208
+ name: 'profile',
209
+ enabled: true,
210
+ transport: 'http',
211
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
212
+ headers: {},
213
+ useUserContext: true,
214
+ },
215
+ ]);
216
+ const registered: any[] = [];
217
+
218
+ await manager.getMCPToolsProvider()(
219
+ {
220
+ registerTools: (tool) => registered.push(tool),
221
+ registerDynamicTools: vi.fn(),
222
+ },
223
+ { ctx: createCtx(9) },
224
+ );
225
+
226
+ expect(registered).toHaveLength(1);
227
+ expect(registered[0].definition.name).toBe('mcp-profile-getProfile');
228
+ expect(mcpClientMock.instances[0].connections.profile.url).toBe('https://mcp.example.test/9');
229
+ });
230
+
231
+ it('lists user-bound tools from ctx', async () => {
232
+ const manager = new DefaultMCPManager(createApp() as any) as any;
233
+ manager.listMCP = vi.fn().mockResolvedValue([
234
+ {
235
+ name: 'profile',
236
+ enabled: true,
237
+ transport: 'http',
238
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
239
+ headers: {},
240
+ useUserContext: true,
241
+ },
242
+ ]);
243
+
244
+ const tools = await manager.listMCPTools(createCtx(11));
245
+
246
+ expect(tools.profile).toEqual([
247
+ {
248
+ name: 'mcp-profile-getProfile',
249
+ title: 'getProfile',
250
+ description: 'Get profile from profile',
251
+ serverName: 'profile',
252
+ permission: 'ALLOW',
253
+ },
254
+ ]);
255
+ expect(mcpClientMock.instances[0].connections.profile.url).toBe('https://mcp.example.test/11');
256
+ });
257
+
258
+ it('returns empty tools and logs warning when user-bound MCP initialization fails', async () => {
259
+ const app = createApp();
260
+ const manager = new UserContextMCPClientManager({
261
+ app,
262
+ listEntries: async () => [
263
+ {
264
+ name: 'profile',
265
+ enabled: true,
266
+ transport: 'http',
267
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
268
+ headers: {},
269
+ useUserContext: true,
270
+ },
271
+ ],
272
+ buildConnection: () => ({ failInitialize: true }) as any,
273
+ });
274
+
275
+ await expect(manager.getToolsMap(createCtx(1))).resolves.toEqual({});
276
+ expect(app.log.warn).toHaveBeenCalledWith('fail to get user-bound mcp tools', expect.any(Error));
277
+ expect(mcpClientMock.instances[0].close).toHaveBeenCalledTimes(1);
278
+ });
279
+
280
+ it('reuses cached user-bound tools and refreshes them after TTL', async () => {
281
+ vi.useFakeTimers();
282
+ vi.setSystemTime(0);
283
+
284
+ const manager = new UserContextMCPClientManager({
285
+ app: createApp(),
286
+ ttlMs: 10,
287
+ maxSize: 10,
288
+ listEntries: async () => [
289
+ {
290
+ name: 'profile',
291
+ enabled: true,
292
+ transport: 'http',
293
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
294
+ headers: {},
295
+ useUserContext: true,
296
+ },
297
+ ],
298
+ buildConnection: (options) => options as any,
299
+ });
300
+
301
+ await manager.getToolsMap(createCtx(1));
302
+ await manager.getToolsMap(createCtx(1));
303
+ expect(mcpClientMock.instances).toHaveLength(1);
304
+
305
+ vi.setSystemTime(11);
306
+ await manager.getToolsMap(createCtx(1));
307
+
308
+ expect(mcpClientMock.instances).toHaveLength(2);
309
+ expect(mcpClientMock.instances[0].close).toHaveBeenCalledTimes(1);
310
+ });
311
+
312
+ it('evicts the oldest user-bound cache entry when max size is exceeded', async () => {
313
+ const manager = new UserContextMCPClientManager({
314
+ app: createApp(),
315
+ ttlMs: 1000,
316
+ maxSize: 1,
317
+ listEntries: async () => [
318
+ {
319
+ name: 'profile',
320
+ enabled: true,
321
+ transport: 'http',
322
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
323
+ headers: {},
324
+ useUserContext: true,
325
+ },
326
+ ],
327
+ buildConnection: (options) => options as any,
328
+ });
329
+
330
+ await manager.getToolsMap(createCtx(1));
331
+ await manager.getToolsMap(createCtx(2));
332
+
333
+ expect(mcpClientMock.instances).toHaveLength(2);
334
+ expect(mcpClientMock.instances[0].close).toHaveBeenCalledTimes(1);
335
+
336
+ await manager.clear();
337
+ expect(mcpClientMock.instances[1].close).toHaveBeenCalledTimes(1);
338
+ });
339
+ });
@@ -85,7 +85,7 @@ describe('MCP loader test cases', () => {
85
85
  title: 'getForecast',
86
86
  description: 'Get weather forecast',
87
87
  serverName: 'weather',
88
- permission: 'ASK',
88
+ permission: 'ALLOW',
89
89
  },
90
90
  {
91
91
  name: 'mcp-weather-setDefaultCity',