@nocobase/ai 2.2.0-alpha.1 → 2.2.0-alpha.2
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 +144 -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 +146 -0
- package/lib/tools-manager/types.d.ts +1 -0
- package/package.json +18 -18
- package/src/__tests__/mcp-user-context.test.ts +339 -0
- package/src/__tests__/mcp.test.ts +1 -1
- package/src/mcp-manager/index.ts +108 -49
- package/src/mcp-manager/options-renderer.ts +132 -0
- package/src/mcp-manager/types.ts +6 -2
- package/src/mcp-manager/user-context-client-manager.ts +150 -0
- package/src/tools-manager/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/ai",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.2",
|
|
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": "
|
|
10
|
-
"@langchain/classic": "
|
|
11
|
-
"@langchain/community": "
|
|
12
|
-
"@langchain/core": "
|
|
13
|
-
"@langchain/deepseek": "
|
|
14
|
-
"@langchain/google-genai": "
|
|
15
|
-
"@langchain/langgraph": "
|
|
16
|
-
"@langchain/langgraph-checkpoint": "
|
|
17
|
-
"@langchain/mcp-adapters": "
|
|
18
|
-
"@langchain/ollama": "
|
|
19
|
-
"@langchain/openai": "
|
|
20
|
-
"@nocobase/data-source-manager": "2.2.0-alpha.
|
|
21
|
-
"@nocobase/logger": "2.2.0-alpha.
|
|
22
|
-
"@nocobase/resourcer": "2.2.0-alpha.
|
|
23
|
-
"@nocobase/utils": "2.2.0-alpha.
|
|
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.2",
|
|
21
|
+
"@nocobase/logger": "2.2.0-alpha.2",
|
|
22
|
+
"@nocobase/resourcer": "2.2.0-alpha.2",
|
|
23
|
+
"@nocobase/utils": "2.2.0-alpha.2",
|
|
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": "
|
|
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": "
|
|
40
|
+
"gitHead": "9dd5224bb8f6c1ac12d766b9f175bdc29bddd845"
|
|
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
|
+
});
|
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() {
|