@nocobase/ai 2.2.0-alpha.1 → 2.2.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/document-loader/index.d.ts +7 -1
- package/lib/document-loader/index.js +9 -4
- package/lib/document-loader/loader.worker.js +29 -20
- package/lib/document-loader/xlsx.d.ts +1 -1
- package/lib/document-loader/xlsx.js +6 -5
- 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__/document-loader.test.ts +63 -0
- package/src/__tests__/mcp-user-context.test.ts +339 -0
- package/src/__tests__/mcp.test.ts +1 -1
- package/src/document-loader/index.ts +18 -4
- package/src/document-loader/loader.worker.ts +30 -23
- package/src/document-loader/xlsx.ts +6 -5
- 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
|
@@ -11,13 +11,22 @@ import { Document } from '@langchain/core/documents';
|
|
|
11
11
|
import { Worker } from 'node:worker_threads';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
-
export
|
|
15
|
-
|
|
14
|
+
export type DocumentLoaderWorkerOptions = {
|
|
15
|
+
filePath: string;
|
|
16
|
+
mimeType?: string;
|
|
17
|
+
/** Timeout in milliseconds for the worker to complete. Defaults to 5 minutes. */
|
|
18
|
+
timeout?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_WORKER_TIMEOUT = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
export const loadByWorker = async (extname: string, options: DocumentLoaderWorkerOptions): Promise<Document[]> => {
|
|
16
24
|
const isTsRuntime = __filename.endsWith('.ts');
|
|
17
25
|
const workerPath = path.join(__dirname, `loader.worker.${isTsRuntime ? 'ts' : 'js'}`);
|
|
18
26
|
const worker = new Worker(workerPath, {
|
|
19
27
|
execArgv: isTsRuntime ? ['--require', 'tsx/cjs'] : undefined,
|
|
20
28
|
});
|
|
29
|
+
const timeout = options.timeout ?? DEFAULT_WORKER_TIMEOUT;
|
|
21
30
|
return new Promise<Document[]>((resolve, reject) => {
|
|
22
31
|
let settled = false;
|
|
23
32
|
const close = (error?: Error, result?: Document[]) => {
|
|
@@ -25,6 +34,7 @@ export const loadByWorker = async (extname: string, blob: Blob): Promise<Documen
|
|
|
25
34
|
return;
|
|
26
35
|
}
|
|
27
36
|
settled = true;
|
|
37
|
+
clearTimeout(timer);
|
|
28
38
|
if (error) {
|
|
29
39
|
reject(error);
|
|
30
40
|
return;
|
|
@@ -32,6 +42,10 @@ export const loadByWorker = async (extname: string, blob: Blob): Promise<Documen
|
|
|
32
42
|
resolve(result || []);
|
|
33
43
|
};
|
|
34
44
|
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
close(new Error(`Document loading timed out after ${Math.round(timeout / 1000)}s`));
|
|
47
|
+
}, timeout);
|
|
48
|
+
|
|
35
49
|
worker.once('message', (payload: { documents?: Document[]; error?: string }) => {
|
|
36
50
|
if (payload?.error) {
|
|
37
51
|
close(new Error(payload.error));
|
|
@@ -48,8 +62,8 @@ export const loadByWorker = async (extname: string, blob: Blob): Promise<Documen
|
|
|
48
62
|
|
|
49
63
|
worker.postMessage({
|
|
50
64
|
extname,
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
filePath: options.filePath,
|
|
66
|
+
mimeType: options.mimeType,
|
|
53
67
|
});
|
|
54
68
|
}).finally(() => {
|
|
55
69
|
worker.terminate().catch(() => undefined);
|
|
@@ -19,7 +19,7 @@ import { loadXlsx } from './xlsx';
|
|
|
19
19
|
type ParsePayload = {
|
|
20
20
|
extname: string;
|
|
21
21
|
mimeType?: string;
|
|
22
|
-
|
|
22
|
+
filePath: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
type WorkerResponse = {
|
|
@@ -27,54 +27,60 @@ type WorkerResponse = {
|
|
|
27
27
|
error?: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
const loadPdf = async (
|
|
31
|
-
const loader = new PDFLoader(
|
|
32
|
-
|
|
30
|
+
const loadPdf = async (filePath: string): Promise<Document[]> => {
|
|
31
|
+
const loader = new PDFLoader(filePath);
|
|
32
|
+
try {
|
|
33
|
+
return await loader.load();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const err = error as Error;
|
|
36
|
+
if (err?.name === 'PasswordException' || /password/i.test(err?.message)) {
|
|
37
|
+
throw new Error('The PDF file is password-protected and cannot be parsed. Please upload an unlocked version.');
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
33
41
|
};
|
|
34
42
|
|
|
35
|
-
const loadDoc = async (
|
|
36
|
-
const loader = new DocxLoader(
|
|
43
|
+
const loadDoc = async (filePath: string, type: 'docx' | 'doc'): Promise<Document[]> => {
|
|
44
|
+
const loader = new DocxLoader(filePath, { type });
|
|
37
45
|
return loader.load();
|
|
38
46
|
};
|
|
39
47
|
|
|
40
|
-
const loadPpt = async (
|
|
41
|
-
const loader = new PPTXLoader(
|
|
48
|
+
const loadPpt = async (filePath: string): Promise<Document[]> => {
|
|
49
|
+
const loader = new PPTXLoader(filePath);
|
|
42
50
|
return loader.load();
|
|
43
51
|
};
|
|
44
52
|
|
|
45
|
-
const loadTxt = async (
|
|
46
|
-
const loader = new TextLoader(
|
|
53
|
+
const loadTxt = async (filePath: string): Promise<Document[]> => {
|
|
54
|
+
const loader = new TextLoader(filePath);
|
|
47
55
|
return loader.load();
|
|
48
56
|
};
|
|
49
57
|
|
|
50
|
-
const loadCsv = async (
|
|
51
|
-
const loader = new CSVLoader(
|
|
58
|
+
const loadCsv = async (filePath: string): Promise<Document[]> => {
|
|
59
|
+
const loader = new CSVLoader(filePath);
|
|
52
60
|
return loader.load();
|
|
53
61
|
};
|
|
54
62
|
|
|
55
63
|
const loadByExtname = async (payload: ParsePayload): Promise<Document[]> => {
|
|
56
|
-
// @ts-ignore
|
|
57
|
-
const blob = new Blob([Buffer.from(payload.buffer)], { type: payload.mimeType ?? 'application/octet-stream' });
|
|
58
|
-
|
|
59
64
|
switch (payload.extname) {
|
|
60
65
|
case '.pdf':
|
|
61
|
-
return loadPdf(
|
|
66
|
+
return loadPdf(payload.filePath);
|
|
62
67
|
case '.ppt':
|
|
63
68
|
case '.pptx':
|
|
64
|
-
return loadPpt(
|
|
69
|
+
return loadPpt(payload.filePath);
|
|
65
70
|
case '.doc':
|
|
66
|
-
return loadDoc(
|
|
71
|
+
return loadDoc(payload.filePath, 'doc');
|
|
67
72
|
case '.docx':
|
|
68
|
-
return loadDoc(
|
|
73
|
+
return loadDoc(payload.filePath, 'docx');
|
|
69
74
|
case '.csv':
|
|
70
|
-
return loadCsv(
|
|
75
|
+
return loadCsv(payload.filePath);
|
|
71
76
|
case '.xls':
|
|
72
77
|
case '.xlsx':
|
|
73
|
-
|
|
78
|
+
case '.xlsm':
|
|
79
|
+
return loadXlsx(payload.filePath, payload.mimeType);
|
|
74
80
|
case '.json':
|
|
75
81
|
case '.md':
|
|
76
82
|
case '.txt':
|
|
77
|
-
return loadTxt(
|
|
83
|
+
return loadTxt(payload.filePath);
|
|
78
84
|
default:
|
|
79
85
|
return [];
|
|
80
86
|
}
|
|
@@ -92,8 +98,9 @@ parentPort?.on('message', async (payload: ParsePayload) => {
|
|
|
92
98
|
};
|
|
93
99
|
parentPort?.postMessage(response);
|
|
94
100
|
} catch (error) {
|
|
101
|
+
const err = error as Error;
|
|
95
102
|
const response: WorkerResponse = {
|
|
96
|
-
error:
|
|
103
|
+
error: err?.message || String(error),
|
|
97
104
|
};
|
|
98
105
|
parentPort?.postMessage(response);
|
|
99
106
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Document } from '@langchain/core/documents';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
11
12
|
import * as XLSX from 'xlsx';
|
|
12
13
|
|
|
13
14
|
const normalizeCellValue = (value: unknown): string => {
|
|
@@ -43,10 +44,10 @@ const sheetToLines = (sheet: XLSX.WorkSheet): string[] => {
|
|
|
43
44
|
.filter((line) => line.trim().length > 0);
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
export const loadXlsx = async (
|
|
47
|
-
const buffer = await
|
|
47
|
+
export const loadXlsx = async (filePath: string, mimeType?: string): Promise<Document[]> => {
|
|
48
|
+
const buffer = await readFile(filePath);
|
|
48
49
|
const workbook = XLSX.read(buffer, {
|
|
49
|
-
type: '
|
|
50
|
+
type: 'buffer',
|
|
50
51
|
cellText: true,
|
|
51
52
|
});
|
|
52
53
|
|
|
@@ -69,8 +70,8 @@ export const loadXlsx = async (blob: Blob): Promise<Document[]> => {
|
|
|
69
70
|
new Document({
|
|
70
71
|
pageContent: [`Sheet: ${sheetName}`, ...lines].join('\n'),
|
|
71
72
|
metadata: {
|
|
72
|
-
source:
|
|
73
|
-
blobType:
|
|
73
|
+
source: filePath,
|
|
74
|
+
blobType: mimeType,
|
|
74
75
|
sheetName,
|
|
75
76
|
sheetIndex: index,
|
|
76
77
|
},
|
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,132 @@
|
|
|
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
|
+
const getRequestVariables = (ctx?: Context) => ({
|
|
88
|
+
headers: ctx?.request?.headers ?? {},
|
|
89
|
+
token: ctx?.getBearerToken?.() ?? '',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const emptyRequestVariables = {
|
|
93
|
+
headers: {},
|
|
94
|
+
token: '',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const normalizeMCPOptions = (options: MCPOptions): MCPOptions => {
|
|
98
|
+
const normalized: MCPOptions = {
|
|
99
|
+
...options,
|
|
100
|
+
args: stringifyArray(options.args),
|
|
101
|
+
env: stringifyRecord(options.env),
|
|
102
|
+
headers: stringifyRecord(options.headers),
|
|
103
|
+
useUserContext: options.transport === 'stdio' ? false : options.useUserContext === true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (normalized.transport === 'stdio') {
|
|
107
|
+
normalized.url = undefined;
|
|
108
|
+
normalized.headers = {};
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export async function renderMCPOptions(
|
|
114
|
+
options: MCPOptions,
|
|
115
|
+
app: { environment?: { getVariables?: () => Record<string, unknown> } },
|
|
116
|
+
ctx?: Context,
|
|
117
|
+
): Promise<MCPOptions> {
|
|
118
|
+
const currentUser = options.useUserContext ? await getCurrentUser(ctx, options) : undefined;
|
|
119
|
+
const request = options.useUserContext ? getRequestVariables(ctx) : emptyRequestVariables;
|
|
120
|
+
const variables = {
|
|
121
|
+
$env: app.environment?.getVariables?.() ?? {},
|
|
122
|
+
currentUser,
|
|
123
|
+
$user: currentUser,
|
|
124
|
+
request,
|
|
125
|
+
ctx: {
|
|
126
|
+
currentUser,
|
|
127
|
+
request,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return normalizeMCPOptions(parse(options)(variables) as MCPOptions);
|
|
132
|
+
}
|
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';
|