@nocobase/ai 2.2.0-beta.6 → 2.2.0-beta.8
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 +6 -6
- 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
|
@@ -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';
|
|
@@ -0,0 +1,150 @@
|
|
|
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 { MultiServerMCPClient, StdioConnection, StreamableHTTPConnection } from '@langchain/mcp-adapters';
|
|
12
|
+
import { StructuredToolInterface } from '@langchain/core/tools';
|
|
13
|
+
import type { MCPEntry, MCPOptions } from './types';
|
|
14
|
+
import { renderMCPOptions } from './options-renderer';
|
|
15
|
+
|
|
16
|
+
type MCPConnection = StdioConnection | StreamableHTTPConnection;
|
|
17
|
+
|
|
18
|
+
type CacheEntry = {
|
|
19
|
+
client: MultiServerMCPClient;
|
|
20
|
+
toolsMap: Record<string, StructuredToolInterface[]>;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
lastAccessedAt: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type UserContextMCPClientManagerOptions = {
|
|
26
|
+
app: any;
|
|
27
|
+
listEntries: () => Promise<MCPEntry[]>;
|
|
28
|
+
buildConnection: (options: MCPOptions) => MCPConnection;
|
|
29
|
+
ttlMs?: number;
|
|
30
|
+
maxSize?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class UserContextMCPClientManager {
|
|
34
|
+
private readonly cache = new Map<string, CacheEntry>();
|
|
35
|
+
private readonly ttlMs: number;
|
|
36
|
+
private readonly maxSize: number;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly options: UserContextMCPClientManagerOptions) {
|
|
39
|
+
this.ttlMs = options.ttlMs ?? 5 * 60 * 1000;
|
|
40
|
+
this.maxSize = options.maxSize ?? 100;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getToolsMap(ctx: Context): Promise<Record<string, StructuredToolInterface[]>> {
|
|
44
|
+
const currentUser = ctx?.state?.currentUser ?? ctx?.auth?.user;
|
|
45
|
+
if (!currentUser?.id) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let client: MultiServerMCPClient | null = null;
|
|
50
|
+
try {
|
|
51
|
+
this.evictExpired();
|
|
52
|
+
|
|
53
|
+
const entries = (await this.options.listEntries())
|
|
54
|
+
.filter((entry) => entry.enabled !== false && entry.useUserContext === true && entry.transport !== 'stdio')
|
|
55
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
56
|
+
if (!entries.length) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cacheKey = String(currentUser.id);
|
|
61
|
+
const cached = this.cache.get(cacheKey);
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (cached && cached.expiresAt > now) {
|
|
64
|
+
cached.lastAccessedAt = now;
|
|
65
|
+
return cached.toolsMap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cached) {
|
|
69
|
+
await this.closeEntry(cached);
|
|
70
|
+
this.cache.delete(cacheKey);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const connections: Record<string, MCPConnection> = {};
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const rendered = await renderMCPOptions(entry, this.options.app, ctx);
|
|
76
|
+
connections[entry.name] = this.options.buildConnection(rendered);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
client = new MultiServerMCPClient(connections);
|
|
80
|
+
const initializedToolsMap = await client.initializeConnections();
|
|
81
|
+
const toolsMap = Object.fromEntries(
|
|
82
|
+
Object.entries(initializedToolsMap).map(([serverName, tools]) => [
|
|
83
|
+
serverName,
|
|
84
|
+
tools as StructuredToolInterface[],
|
|
85
|
+
]),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
this.cache.set(cacheKey, {
|
|
89
|
+
client,
|
|
90
|
+
toolsMap,
|
|
91
|
+
expiresAt: now + this.ttlMs,
|
|
92
|
+
lastAccessedAt: now,
|
|
93
|
+
});
|
|
94
|
+
client = null;
|
|
95
|
+
await this.evictOversized();
|
|
96
|
+
|
|
97
|
+
return toolsMap;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (client) {
|
|
100
|
+
await this.closeEntry({
|
|
101
|
+
client,
|
|
102
|
+
toolsMap: {},
|
|
103
|
+
expiresAt: 0,
|
|
104
|
+
lastAccessedAt: 0,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
this.options.app?.log?.warn?.('fail to get user-bound mcp tools', error);
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async clear() {
|
|
113
|
+
const entries = [...this.cache.values()];
|
|
114
|
+
this.cache.clear();
|
|
115
|
+
await Promise.all(entries.map((entry) => this.closeEntry(entry)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private evictExpired() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
121
|
+
if (entry.expiresAt <= now) {
|
|
122
|
+
this.cache.delete(key);
|
|
123
|
+
this.closeEntry(entry).catch((error) => {
|
|
124
|
+
this.options.app?.log?.warn?.('fail to close expired user-bound mcp client', error);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async evictOversized() {
|
|
131
|
+
while (this.cache.size > this.maxSize) {
|
|
132
|
+
const oldest = [...this.cache.entries()].sort(
|
|
133
|
+
([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt,
|
|
134
|
+
)[0];
|
|
135
|
+
if (!oldest) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.cache.delete(oldest[0]);
|
|
139
|
+
await this.closeEntry(oldest[1]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async closeEntry(entry: CacheEntry) {
|
|
144
|
+
try {
|
|
145
|
+
await entry.client.close();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.options.app?.log?.warn?.('fail to close user-bound mcp client', error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|