@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.
- package/lib/mcp-manager/index.d.ts +8 -2
- package/lib/mcp-manager/index.js +95 -50
- package/lib/mcp-manager/options-renderer.d.ts +16 -0
- package/lib/mcp-manager/options-renderer.js +130 -0
- package/lib/mcp-manager/types.d.ts +6 -2
- package/lib/mcp-manager/user-context-client-manager.d.ts +33 -0
- package/lib/mcp-manager/user-context-client-manager.js +131 -0
- package/lib/tools-manager/types.d.ts +1 -0
- package/package.json +6 -6
- package/src/__tests__/mcp-user-context.test.ts +265 -0
- package/src/__tests__/mcp.test.ts +1 -1
- package/src/mcp-manager/index.ts +108 -49
- package/src/mcp-manager/options-renderer.ts +119 -0
- package/src/mcp-manager/types.ts +6 -2
- package/src/mcp-manager/user-context-client-manager.ts +135 -0
- package/src/tools-manager/types.ts +1 -0
|
@@ -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
|
+
});
|
package/src/mcp-manager/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
209
|
+
private formatMCPTools(toolsMap: Record<string, StructuredToolInterface[]>): Record<string, MCPToolEntry[]> {
|
|
161
210
|
return Object.fromEntries(
|
|
162
|
-
Object.entries(
|
|
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
|
|
183
|
-
|
|
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' && !
|
|
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') && !
|
|
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(
|
|
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:
|
|
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:
|
|
312
|
-
command:
|
|
313
|
-
args:
|
|
314
|
-
env:
|
|
315
|
-
url:
|
|
316
|
-
headers:
|
|
317
|
-
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
|
-
...
|
|
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
|
-
|
|
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
|
+
}
|
package/src/mcp-manager/types.ts
CHANGED
|
@@ -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';
|