@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,265 @@
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
+ return Object.fromEntries(
24
+ Object.keys(this.connections).map((serverName) => [
25
+ serverName,
26
+ [
27
+ {
28
+ name: 'getProfile',
29
+ description: `Get profile from ${serverName}`,
30
+ schema: {},
31
+ invoke: vi.fn(async (args) => ({ serverName, args })),
32
+ },
33
+ ],
34
+ ]),
35
+ );
36
+ }
37
+ }
38
+
39
+ return {
40
+ instances,
41
+ MultiServerMCPClient,
42
+ };
43
+ });
44
+
45
+ vi.mock('@langchain/mcp-adapters', () => ({
46
+ MultiServerMCPClient: mcpClientMock.MultiServerMCPClient,
47
+ }));
48
+
49
+ import { DefaultMCPManager } from '../mcp-manager';
50
+ import { normalizeMCPOptions, renderMCPOptions } from '../mcp-manager/options-renderer';
51
+ import { UserContextMCPClientManager } from '../mcp-manager/user-context-client-manager';
52
+
53
+ describe('user-bound MCP clients', () => {
54
+ const createApp = () => ({
55
+ environment: {
56
+ getVariables: () => ({
57
+ MCP_HOST: 'mcp.example.test',
58
+ API_TOKEN: 'env-token',
59
+ }),
60
+ },
61
+ log: {
62
+ warn: vi.fn(),
63
+ },
64
+ });
65
+
66
+ const createCtx = (id: number, extraUser: Record<string, unknown> = {}) =>
67
+ ({
68
+ state: {
69
+ currentUser: {
70
+ id,
71
+ name: `user-${id}`,
72
+ ...extraUser,
73
+ },
74
+ },
75
+ auth: {
76
+ user: {
77
+ id,
78
+ name: `user-${id}`,
79
+ ...extraUser,
80
+ },
81
+ },
82
+ db: {
83
+ getRepository: () => ({
84
+ findOne: vi.fn(),
85
+ }),
86
+ },
87
+ }) as any;
88
+
89
+ beforeEach(() => {
90
+ mcpClientMock.instances.length = 0;
91
+ });
92
+
93
+ afterEach(() => {
94
+ vi.useRealTimers();
95
+ });
96
+
97
+ it('normalizes stdio records as not user-bound', () => {
98
+ expect(
99
+ normalizeMCPOptions({
100
+ transport: 'stdio',
101
+ command: 'node',
102
+ args: ['server.js'],
103
+ env: { TOKEN: '{{ $env.API_TOKEN }}' },
104
+ useUserContext: true,
105
+ }),
106
+ ).toMatchObject({
107
+ transport: 'stdio',
108
+ command: 'node',
109
+ args: ['server.js'],
110
+ env: { TOKEN: '{{ $env.API_TOKEN }}' },
111
+ useUserContext: false,
112
+ headers: {},
113
+ });
114
+ });
115
+
116
+ it('renders environment and current user variables in MCP options', async () => {
117
+ const rendered = await renderMCPOptions(
118
+ {
119
+ transport: 'http',
120
+ url: 'https://{{ $env.MCP_HOST }}/users/{{ currentUser.id }}',
121
+ headers: {
122
+ Authorization: 'Bearer {{ $env.API_TOKEN }}',
123
+ 'X-User': '{{ $user.name }}',
124
+ },
125
+ useUserContext: true,
126
+ },
127
+ createApp(),
128
+ createCtx(7),
129
+ );
130
+
131
+ expect(rendered).toMatchObject({
132
+ transport: 'http',
133
+ url: 'https://mcp.example.test/users/7',
134
+ headers: {
135
+ Authorization: 'Bearer env-token',
136
+ 'X-User': 'user-7',
137
+ },
138
+ useUserContext: true,
139
+ });
140
+ });
141
+
142
+ it('excludes user-bound records when rebuilding the shared client', async () => {
143
+ const manager = new DefaultMCPManager(createApp() as any) as any;
144
+ manager.listMCP = vi.fn().mockResolvedValue([]);
145
+
146
+ await manager.rebuildClient();
147
+
148
+ expect(manager.listMCP).toHaveBeenCalledWith({ enabled: true, useUserContext: false });
149
+ expect(mcpClientMock.instances).toHaveLength(0);
150
+ });
151
+
152
+ it('registers user-bound tools from filter ctx', async () => {
153
+ const manager = new DefaultMCPManager(createApp() as any) as any;
154
+ manager.listMCP = vi.fn().mockResolvedValue([
155
+ {
156
+ name: 'profile',
157
+ enabled: true,
158
+ transport: 'http',
159
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
160
+ headers: {},
161
+ useUserContext: true,
162
+ },
163
+ ]);
164
+ const registered: any[] = [];
165
+
166
+ await manager.getMCPToolsProvider()(
167
+ {
168
+ registerTools: (tool) => registered.push(tool),
169
+ registerDynamicTools: vi.fn(),
170
+ },
171
+ { ctx: createCtx(9) },
172
+ );
173
+
174
+ expect(registered).toHaveLength(1);
175
+ expect(registered[0].definition.name).toBe('mcp-profile-getProfile');
176
+ expect(mcpClientMock.instances[0].connections.profile.url).toBe('https://mcp.example.test/9');
177
+ });
178
+
179
+ it('lists user-bound tools from ctx', async () => {
180
+ const manager = new DefaultMCPManager(createApp() as any) as any;
181
+ manager.listMCP = vi.fn().mockResolvedValue([
182
+ {
183
+ name: 'profile',
184
+ enabled: true,
185
+ transport: 'http',
186
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
187
+ headers: {},
188
+ useUserContext: true,
189
+ },
190
+ ]);
191
+
192
+ const tools = await manager.listMCPTools(createCtx(11));
193
+
194
+ expect(tools.profile).toEqual([
195
+ {
196
+ name: 'mcp-profile-getProfile',
197
+ title: 'getProfile',
198
+ description: 'Get profile from profile',
199
+ serverName: 'profile',
200
+ permission: 'ALLOW',
201
+ },
202
+ ]);
203
+ expect(mcpClientMock.instances[0].connections.profile.url).toBe('https://mcp.example.test/11');
204
+ });
205
+
206
+ it('reuses cached user-bound tools and refreshes them after TTL', async () => {
207
+ vi.useFakeTimers();
208
+ vi.setSystemTime(0);
209
+
210
+ const manager = new UserContextMCPClientManager({
211
+ app: createApp(),
212
+ ttlMs: 10,
213
+ maxSize: 10,
214
+ listEntries: async () => [
215
+ {
216
+ name: 'profile',
217
+ enabled: true,
218
+ transport: 'http',
219
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
220
+ headers: {},
221
+ useUserContext: true,
222
+ },
223
+ ],
224
+ buildConnection: (options) => options as any,
225
+ });
226
+
227
+ await manager.getToolsMap(createCtx(1));
228
+ await manager.getToolsMap(createCtx(1));
229
+ expect(mcpClientMock.instances).toHaveLength(1);
230
+
231
+ vi.setSystemTime(11);
232
+ await manager.getToolsMap(createCtx(1));
233
+
234
+ expect(mcpClientMock.instances).toHaveLength(2);
235
+ expect(mcpClientMock.instances[0].close).toHaveBeenCalledTimes(1);
236
+ });
237
+
238
+ it('evicts the oldest user-bound cache entry when max size is exceeded', async () => {
239
+ const manager = new UserContextMCPClientManager({
240
+ app: createApp(),
241
+ ttlMs: 1000,
242
+ maxSize: 1,
243
+ listEntries: async () => [
244
+ {
245
+ name: 'profile',
246
+ enabled: true,
247
+ transport: 'http',
248
+ url: 'https://{{ $env.MCP_HOST }}/{{ currentUser.id }}',
249
+ headers: {},
250
+ useUserContext: true,
251
+ },
252
+ ],
253
+ buildConnection: (options) => options as any,
254
+ });
255
+
256
+ await manager.getToolsMap(createCtx(1));
257
+ await manager.getToolsMap(createCtx(2));
258
+
259
+ expect(mcpClientMock.instances).toHaveLength(2);
260
+ expect(mcpClientMock.instances[0].close).toHaveBeenCalledTimes(1);
261
+
262
+ await manager.clear();
263
+ expect(mcpClientMock.instances[1].close).toHaveBeenCalledTimes(1);
264
+ });
265
+ });
@@ -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() {
@@ -0,0 +1,119 @@
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 { parse } from '@nocobase/utils';
12
+ import type { MCPOptions } from './types';
13
+
14
+ const unsafePathSegments = new Set(['__proto__', 'prototype', 'constructor']);
15
+ const currentUserVariableRegExp = /{{\s*(?:(ctx)\.)?(currentUser|\$user)(?:\.([^}]+))?\s*}}/g;
16
+
17
+ const hasUnsafePathSegment = (path: string) => {
18
+ return path
19
+ .split(/[.[\]]+/)
20
+ .filter(Boolean)
21
+ .some((segment) => unsafePathSegments.has(segment));
22
+ };
23
+
24
+ const toPlainObject = (value: any) => {
25
+ if (!value) {
26
+ return value;
27
+ }
28
+ if (typeof value.toJSON === 'function') {
29
+ return value.toJSON();
30
+ }
31
+ return value;
32
+ };
33
+
34
+ const getCurrentUserReferencePaths = (value: unknown) => {
35
+ const text = typeof value === 'string' ? value : JSON.stringify(value ?? {});
36
+ return Array.from(text.matchAll(currentUserVariableRegExp))
37
+ .map((match) => match[3]?.trim())
38
+ .filter((path): path is string => !!path && !hasUnsafePathSegment(path));
39
+ };
40
+
41
+ const getCurrentUserAppends = (paths: string[], currentUser: any) => {
42
+ return Array.from(
43
+ new Set(
44
+ paths.map((path) => path.split('.')[0]).filter((append) => append && !Reflect.has(currentUser || {}, append)),
45
+ ),
46
+ );
47
+ };
48
+
49
+ async function getCurrentUser(ctx?: Context, template?: unknown) {
50
+ const currentUser = ctx?.state?.currentUser ?? ctx?.auth?.user;
51
+ if (!ctx || !currentUser) {
52
+ return toPlainObject(currentUser);
53
+ }
54
+
55
+ const appends = getCurrentUserAppends(getCurrentUserReferencePaths(template), currentUser);
56
+ if (!appends.length) {
57
+ return toPlainObject(currentUser);
58
+ }
59
+
60
+ const user = await ctx.db.getRepository('users').findOne({
61
+ filterByTk: currentUser.id,
62
+ appends,
63
+ });
64
+ return toPlainObject(user) || toPlainObject(currentUser);
65
+ }
66
+
67
+ const stringifyRecord = (value: unknown): Record<string, string> => {
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
69
+ return {};
70
+ }
71
+ return Object.entries(value as Record<string, unknown>).reduce<Record<string, string>>((result, [key, item]) => {
72
+ if (!key || item == null) {
73
+ return result;
74
+ }
75
+ result[key] = String(item);
76
+ return result;
77
+ }, {});
78
+ };
79
+
80
+ const stringifyArray = (value: unknown): string[] => {
81
+ if (!Array.isArray(value)) {
82
+ return [];
83
+ }
84
+ return value.filter((item) => item != null).map((item) => String(item));
85
+ };
86
+
87
+ export const normalizeMCPOptions = (options: MCPOptions): MCPOptions => {
88
+ const normalized: MCPOptions = {
89
+ ...options,
90
+ args: stringifyArray(options.args),
91
+ env: stringifyRecord(options.env),
92
+ headers: stringifyRecord(options.headers),
93
+ useUserContext: options.transport === 'stdio' ? false : options.useUserContext === true,
94
+ };
95
+
96
+ if (normalized.transport === 'stdio') {
97
+ normalized.url = undefined;
98
+ normalized.headers = {};
99
+ }
100
+ return normalized;
101
+ };
102
+
103
+ export async function renderMCPOptions(
104
+ options: MCPOptions,
105
+ app: { environment?: { getVariables?: () => Record<string, unknown> } },
106
+ ctx?: Context,
107
+ ): Promise<MCPOptions> {
108
+ const currentUser = options.useUserContext ? await getCurrentUser(ctx, options) : undefined;
109
+ const variables = {
110
+ $env: app.environment?.getVariables?.() ?? {},
111
+ currentUser,
112
+ $user: currentUser,
113
+ ctx: {
114
+ currentUser,
115
+ },
116
+ };
117
+
118
+ return normalizeMCPOptions(parse(options)(variables) as MCPOptions);
119
+ }
@@ -8,18 +8,20 @@
8
8
  */
9
9
 
10
10
  import type { MultiServerMCPClient } from '@langchain/mcp-adapters';
11
+ import type { Context } from '@nocobase/actions';
11
12
  import type { DynamicToolsProvider, Permission } from '../tools-manager/types';
12
13
 
13
14
  export interface MCPManager extends MCPRegistration {
14
15
  init(): Promise<void>;
15
16
  getMCP(name: string): Promise<MCPEntry>;
16
17
  listMCP(filter: MCPFilter): Promise<MCPEntry[]>;
17
- testConnection(options: MCPOptions): Promise<MCPTestResult>;
18
+ testConnection(options: MCPOptions, ctx?: Context): Promise<MCPTestResult>;
18
19
  rebuildClient(): Promise<void>;
19
20
  getClient(): MultiServerMCPClient | null;
20
21
  getMCPToolsProvider(): DynamicToolsProvider;
21
- listMCPTools(): Promise<Record<string, MCPToolEntry[]>>;
22
+ listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>>;
22
23
  updateMCPToolPermission(toolName: string, permission: Permission): Promise<void>;
24
+ clearUserContextCache(): Promise<void>;
23
25
  }
24
26
 
25
27
  export interface MCPRegistration {
@@ -34,6 +36,7 @@ export type MCPOptions = {
34
36
  url?: string;
35
37
  headers?: Record<string, string>;
36
38
  restart?: Record<string, any>;
39
+ useUserContext?: boolean;
37
40
  };
38
41
 
39
42
  export type MCPEntry = MCPOptions & {
@@ -45,6 +48,7 @@ export type MCPFilter = {
45
48
  name?: string;
46
49
  enabled?: boolean;
47
50
  transport?: MCPTransport;
51
+ useUserContext?: boolean;
48
52
  };
49
53
 
50
54
  export type MCPTransport = 'stdio' | 'sse' | 'http';