@nocobase/ai 2.1.9 → 2.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/ai",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./lib/index.js",
@@ -17,10 +17,10 @@
17
17
  "@langchain/mcp-adapters": "1.1.3",
18
18
  "@langchain/ollama": "1.2.7",
19
19
  "@langchain/openai": "1.4.7",
20
- "@nocobase/data-source-manager": "2.1.9",
21
- "@nocobase/logger": "2.1.9",
22
- "@nocobase/resourcer": "2.1.9",
23
- "@nocobase/utils": "2.1.9",
20
+ "@nocobase/data-source-manager": "2.1.11",
21
+ "@nocobase/logger": "2.1.11",
22
+ "@nocobase/resourcer": "2.1.11",
23
+ "@nocobase/utils": "2.1.11",
24
24
  "d3-dsv": "2",
25
25
  "fast-glob": "^3.3.2",
26
26
  "flexsearch": "^0.8.2",
@@ -37,5 +37,5 @@
37
37
  "url": "git+https://github.com/nocobase/nocobase.git",
38
38
  "directory": "packages/ai"
39
39
  },
40
- "gitHead": "2b612d81c6f6115af75ea7b279384751af869510"
40
+ "gitHead": "1ccf891d837e21089f65f84b892407b34a0a0cb9"
41
41
  }
@@ -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',
@@ -15,6 +15,8 @@ import { StructuredToolInterface } from '@langchain/core/tools';
15
15
  import { MCPEntry, MCPFilter, MCPManager, MCPOptions, MCPTestResult, MCPToolEntry } from './types';
16
16
  import type { DynamicToolsProvider, Permission, ToolsRegistration, ToolsOptions } from '../tools-manager/types';
17
17
  import type { Context } from '@nocobase/actions';
18
+ import { normalizeMCPOptions, renderMCPOptions } from './options-renderer';
19
+ import { UserContextMCPClientManager } from './user-context-client-manager';
18
20
 
19
21
  export class DefaultMCPManager implements MCPManager {
20
22
  private readonly mcpRegistry = new Registry<MCPEntry>();
@@ -23,9 +25,15 @@ export class DefaultMCPManager implements MCPManager {
23
25
  private client: MultiServerMCPClient | null = null;
24
26
  private toolsMap: Record<string, StructuredToolInterface[]> = {};
25
27
  private toolsPermissionMap: Record<string, Permission> = {};
28
+ private readonly userContextClientManager: UserContextMCPClientManager;
26
29
 
27
30
  constructor(private readonly app: any) {
28
31
  this.provideCollectionManager = () => app.mainDataSource;
32
+ this.userContextClientManager = new UserContextMCPClientManager({
33
+ app,
34
+ listEntries: () => this.listMCP({ enabled: true, useUserContext: true }),
35
+ buildConnection: (options) => this.buildMCPConnection(options),
36
+ });
29
37
  }
30
38
 
31
39
  async init() {
@@ -72,6 +80,14 @@ export class DefaultMCPManager implements MCPManager {
72
80
  if (filter.transport) {
73
81
  where['transport'] = filter.transport;
74
82
  }
83
+ if (filter.useUserContext != null) {
84
+ where['useUserContext'] =
85
+ filter.useUserContext === true
86
+ ? true
87
+ : {
88
+ [Op.or]: [false, null],
89
+ };
90
+ }
75
91
  return (await this.aiMcpClientsModel?.findAll({ where }))?.map((item) => item.toJSON() as MCPEntry) ?? [];
76
92
  }
77
93
 
@@ -88,7 +104,7 @@ export class DefaultMCPManager implements MCPManager {
88
104
  }
89
105
 
90
106
  // Get all enabled MCP entries
91
- const entries = await this.listMCP({ enabled: true });
107
+ const entries = await this.listMCP({ enabled: true, useUserContext: false });
92
108
 
93
109
  if (entries.length === 0) {
94
110
  return;
@@ -97,7 +113,7 @@ export class DefaultMCPManager implements MCPManager {
97
113
  // Build connections object
98
114
  const connections: Record<string, StdioConnection | StreamableHTTPConnection> = {};
99
115
  for (const entry of entries) {
100
- connections[entry.name] = this.buildMCPConnection(entry);
116
+ connections[entry.name] = this.buildMCPConnection(await renderMCPOptions(entry, this.app));
101
117
  }
102
118
 
103
119
  // Create new client and initialize connections
@@ -121,48 +137,82 @@ export class DefaultMCPManager implements MCPManager {
121
137
  }
122
138
 
123
139
  getMCPToolsProvider(): DynamicToolsProvider {
124
- return async (register: ToolsRegistration): Promise<void> => {
140
+ return async (register: ToolsRegistration, filter): Promise<void> => {
125
141
  // Use cached tools from rebuildClient
126
142
  for (const [serverName, tools] of Object.entries(this.toolsMap)) {
127
- for (const tool of tools) {
128
- const toolName = `mcp-${serverName}-${tool.name}`;
129
- const toolOptions: ToolsOptions = {
130
- scope: 'GENERAL',
131
- from: 'mcp',
132
- defaultPermission: this.toolsPermissionMap[toolName],
133
- introduction: {
134
- title: tool.name,
135
- about: tool.description,
136
- },
137
- definition: {
138
- name: toolName,
139
- description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
140
- schema: tool.schema,
141
- },
142
- invoke: async (_ctx: Context, args: any) => {
143
- try {
144
- const result = await tool.invoke(args);
145
- return result;
146
- } catch (error: any) {
147
- return {
148
- status: 'error' as const,
149
- content: error?.message || 'Tool invocation failed',
150
- };
151
- }
152
- },
153
- };
154
- register.registerTools(toolOptions);
155
- }
143
+ this.registerToolsFromMap(register, serverName, tools);
156
144
  }
145
+
146
+ if (!filter?.ctx) {
147
+ return;
148
+ }
149
+
150
+ const userToolsMap = await this.userContextClientManager.getToolsMap(filter.ctx);
151
+ for (const [serverName, tools] of Object.entries(userToolsMap)) {
152
+ this.registerToolsFromMap(register, serverName, tools);
153
+ }
154
+ };
155
+ }
156
+
157
+ private registerToolsFromMap(
158
+ register: ToolsRegistration,
159
+ serverName: string,
160
+ tools: StructuredToolInterface[],
161
+ ): void {
162
+ for (const tool of tools) {
163
+ const toolName = `mcp-${serverName}-${tool.name}`;
164
+ this.ensureToolPermission(toolName, tool.name);
165
+ const toolOptions: ToolsOptions = {
166
+ scope: 'GENERAL',
167
+ from: 'mcp',
168
+ defaultPermission: this.toolsPermissionMap[toolName],
169
+ introduction: {
170
+ title: tool.name,
171
+ about: tool.description,
172
+ },
173
+ definition: {
174
+ name: toolName,
175
+ description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
176
+ schema: tool.schema,
177
+ },
178
+ invoke: async (_ctx: Context, args: any) => {
179
+ try {
180
+ const result = await tool.invoke(args);
181
+ return result;
182
+ } catch (error: any) {
183
+ return {
184
+ status: 'error' as const,
185
+ content: error?.message || 'Tool invocation failed',
186
+ };
187
+ }
188
+ },
189
+ };
190
+ register.registerTools(toolOptions);
191
+ }
192
+ }
193
+
194
+ private ensureToolPermission(toolName: string, rawToolName: string) {
195
+ if (!(toolName in this.toolsPermissionMap)) {
196
+ this.toolsPermissionMap[toolName] = rawToolName.startsWith('get') ? 'ALLOW' : 'ASK';
197
+ }
198
+ }
199
+
200
+ async listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>> {
201
+ const toolsMap = {
202
+ ...this.toolsMap,
203
+ ...(ctx ? await this.userContextClientManager.getToolsMap(ctx) : {}),
157
204
  };
205
+
206
+ return this.formatMCPTools(toolsMap);
158
207
  }
159
208
 
160
- async listMCPTools(): Promise<Record<string, MCPToolEntry[]>> {
209
+ private formatMCPTools(toolsMap: Record<string, StructuredToolInterface[]>): Record<string, MCPToolEntry[]> {
161
210
  return Object.fromEntries(
162
- Object.entries(this.toolsMap).map(([serverName, tools]) => [
211
+ Object.entries(toolsMap).map(([serverName, tools]) => [
163
212
  serverName,
164
213
  tools.map((tool) => {
165
214
  const toolName = `mcp-${serverName}-${tool.name}`;
215
+ this.ensureToolPermission(toolName, tool.name);
166
216
  return {
167
217
  name: toolName,
168
218
  title: tool.name,
@@ -179,8 +229,13 @@ export class DefaultMCPManager implements MCPManager {
179
229
  this.toolsPermissionMap[toolName] = permission;
180
230
  }
181
231
 
182
- async testConnection(options: MCPOptions): Promise<MCPTestResult> {
183
- const { transport } = options;
232
+ async clearUserContextCache(): Promise<void> {
233
+ await this.userContextClientManager.clear();
234
+ }
235
+
236
+ async testConnection(options: MCPOptions, ctx?: Context): Promise<MCPTestResult> {
237
+ const renderedOptions = await renderMCPOptions(normalizeMCPOptions(options), this.app, ctx);
238
+ const { transport } = renderedOptions;
184
239
 
185
240
  // Validate required fields
186
241
  if (!transport) {
@@ -190,14 +245,14 @@ export class DefaultMCPManager implements MCPManager {
190
245
  };
191
246
  }
192
247
 
193
- if (transport === 'stdio' && !options.command) {
248
+ if (transport === 'stdio' && !renderedOptions.command) {
194
249
  return {
195
250
  success: false,
196
251
  error: 'Command is required for stdio transport',
197
252
  };
198
253
  }
199
254
 
200
- if ((transport === 'http' || transport === 'sse') && !options.url) {
255
+ if ((transport === 'http' || transport === 'sse') && !renderedOptions.url) {
201
256
  return {
202
257
  success: false,
203
258
  error: 'URL is required for HTTP/SSE transport',
@@ -207,7 +262,7 @@ export class DefaultMCPManager implements MCPManager {
207
262
  let client: MultiServerMCPClient | null = null;
208
263
 
209
264
  try {
210
- const connection = this.buildMCPConnection(options);
265
+ const connection = this.buildMCPConnection(renderedOptions);
211
266
  const serverName = 'test-server';
212
267
 
213
268
  client = new MultiServerMCPClient({
@@ -303,18 +358,20 @@ export class DefaultMCPManager implements MCPManager {
303
358
  }
304
359
 
305
360
  private async persistenceEntry(entry: MCPEntry): Promise<void> {
361
+ const normalizedEntry = normalizeMCPOptions(entry) as MCPEntry;
306
362
  await this.sequelize.transaction(async (transaction) => {
307
- const existed = await this.aiMcpClientsModel.findOne({ where: { name: entry.name }, transaction });
363
+ const existed = await this.aiMcpClientsModel.findOne({ where: { name: normalizedEntry.name }, transaction });
308
364
  if (existed) {
309
365
  await existed.update(
310
366
  {
311
- transport: entry.transport,
312
- command: entry.command,
313
- args: entry.args,
314
- env: entry.env,
315
- url: entry.url,
316
- headers: entry.headers,
317
- restart: entry.restart,
367
+ transport: normalizedEntry.transport,
368
+ command: normalizedEntry.command,
369
+ args: normalizedEntry.args,
370
+ env: normalizedEntry.env,
371
+ url: normalizedEntry.url,
372
+ headers: normalizedEntry.headers,
373
+ restart: normalizedEntry.restart,
374
+ useUserContext: normalizedEntry.useUserContext,
318
375
  },
319
376
  { transaction },
320
377
  );
@@ -323,7 +380,7 @@ export class DefaultMCPManager implements MCPManager {
323
380
 
324
381
  await this.aiMcpClientsModel.create(
325
382
  {
326
- ...entry,
383
+ ...normalizedEntry,
327
384
  },
328
385
  { transaction },
329
386
  );
@@ -331,13 +388,15 @@ export class DefaultMCPManager implements MCPManager {
331
388
  }
332
389
 
333
390
  private normalizeEntry(name: string, options: MCPOptions): MCPEntry {
334
- return {
391
+ const entry: MCPEntry = {
335
392
  name,
336
393
  enabled: true,
337
394
  ...options,
338
395
  args: options.args ?? [],
339
396
  env: options.env ?? {},
397
+ useUserContext: options.useUserContext === true,
340
398
  };
399
+ return normalizeMCPOptions(entry) as MCPEntry;
341
400
  }
342
401
 
343
402
  private get aiMcpClientsCollection() {