@nocobase/ai 2.1.10 → 2.1.11
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/options-renderer.js +15 -1
- package/lib/mcp-manager/user-context-client-manager.js +51 -36
- package/package.json +6 -6
- package/src/__tests__/mcp-user-context.test.ts +74 -0
- package/src/mcp-manager/options-renderer.ts +13 -0
- package/src/mcp-manager/user-context-client-manager.ts +56 -41
|
@@ -95,6 +95,17 @@ const stringifyArray = /* @__PURE__ */ __name((value) => {
|
|
|
95
95
|
}
|
|
96
96
|
return value.filter((item) => item != null).map((item) => String(item));
|
|
97
97
|
}, "stringifyArray");
|
|
98
|
+
const getRequestVariables = /* @__PURE__ */ __name((ctx) => {
|
|
99
|
+
var _a, _b;
|
|
100
|
+
return {
|
|
101
|
+
headers: ((_a = ctx == null ? void 0 : ctx.request) == null ? void 0 : _a.headers) ?? {},
|
|
102
|
+
token: ((_b = ctx == null ? void 0 : ctx.getBearerToken) == null ? void 0 : _b.call(ctx)) ?? ""
|
|
103
|
+
};
|
|
104
|
+
}, "getRequestVariables");
|
|
105
|
+
const emptyRequestVariables = {
|
|
106
|
+
headers: {},
|
|
107
|
+
token: ""
|
|
108
|
+
};
|
|
98
109
|
const normalizeMCPOptions = /* @__PURE__ */ __name((options) => {
|
|
99
110
|
const normalized = {
|
|
100
111
|
...options,
|
|
@@ -112,12 +123,15 @@ const normalizeMCPOptions = /* @__PURE__ */ __name((options) => {
|
|
|
112
123
|
async function renderMCPOptions(options, app, ctx) {
|
|
113
124
|
var _a, _b;
|
|
114
125
|
const currentUser = options.useUserContext ? await getCurrentUser(ctx, options) : void 0;
|
|
126
|
+
const request = options.useUserContext ? getRequestVariables(ctx) : emptyRequestVariables;
|
|
115
127
|
const variables = {
|
|
116
128
|
$env: ((_b = (_a = app.environment) == null ? void 0 : _a.getVariables) == null ? void 0 : _b.call(_a)) ?? {},
|
|
117
129
|
currentUser,
|
|
118
130
|
$user: currentUser,
|
|
131
|
+
request,
|
|
119
132
|
ctx: {
|
|
120
|
-
currentUser
|
|
133
|
+
currentUser,
|
|
134
|
+
request
|
|
121
135
|
}
|
|
122
136
|
};
|
|
123
137
|
return normalizeMCPOptions((0, import_utils.parse)(options)(variables));
|
|
@@ -42,48 +42,63 @@ const _UserContextMCPClientManager = class _UserContextMCPClientManager {
|
|
|
42
42
|
ttlMs;
|
|
43
43
|
maxSize;
|
|
44
44
|
async getToolsMap(ctx) {
|
|
45
|
-
var _a, _b;
|
|
45
|
+
var _a, _b, _c, _d, _e;
|
|
46
46
|
const currentUser = ((_a = ctx == null ? void 0 : ctx.state) == null ? void 0 : _a.currentUser) ?? ((_b = ctx == null ? void 0 : ctx.auth) == null ? void 0 : _b.user);
|
|
47
47
|
if (!(currentUser == null ? void 0 : currentUser.id)) {
|
|
48
48
|
return {};
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
let client = null;
|
|
51
|
+
try {
|
|
52
|
+
this.evictExpired();
|
|
53
|
+
const entries = (await this.options.listEntries()).filter((entry) => entry.enabled !== false && entry.useUserContext === true && entry.transport !== "stdio").sort((left, right) => left.name.localeCompare(right.name));
|
|
54
|
+
if (!entries.length) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
const cacheKey = String(currentUser.id);
|
|
58
|
+
const cached = this.cache.get(cacheKey);
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (cached && cached.expiresAt > now) {
|
|
61
|
+
cached.lastAccessedAt = now;
|
|
62
|
+
return cached.toolsMap;
|
|
63
|
+
}
|
|
64
|
+
if (cached) {
|
|
65
|
+
await this.closeEntry(cached);
|
|
66
|
+
this.cache.delete(cacheKey);
|
|
67
|
+
}
|
|
68
|
+
const connections = {};
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const rendered = await (0, import_options_renderer.renderMCPOptions)(entry, this.options.app, ctx);
|
|
71
|
+
connections[entry.name] = this.options.buildConnection(rendered);
|
|
72
|
+
}
|
|
73
|
+
client = new import_mcp_adapters.MultiServerMCPClient(connections);
|
|
74
|
+
const initializedToolsMap = await client.initializeConnections();
|
|
75
|
+
const toolsMap = Object.fromEntries(
|
|
76
|
+
Object.entries(initializedToolsMap).map(([serverName, tools]) => [
|
|
77
|
+
serverName,
|
|
78
|
+
tools
|
|
79
|
+
])
|
|
80
|
+
);
|
|
81
|
+
this.cache.set(cacheKey, {
|
|
82
|
+
client,
|
|
83
|
+
toolsMap,
|
|
84
|
+
expiresAt: now + this.ttlMs,
|
|
85
|
+
lastAccessedAt: now
|
|
86
|
+
});
|
|
87
|
+
client = null;
|
|
88
|
+
await this.evictOversized();
|
|
89
|
+
return toolsMap;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (client) {
|
|
92
|
+
await this.closeEntry({
|
|
93
|
+
client,
|
|
94
|
+
toolsMap: {},
|
|
95
|
+
expiresAt: 0,
|
|
96
|
+
lastAccessedAt: 0
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
(_e = (_d = (_c = this.options.app) == null ? void 0 : _c.log) == null ? void 0 : _d.warn) == null ? void 0 : _e.call(_d, "fail to get user-bound mcp tools", error);
|
|
53
100
|
return {};
|
|
54
101
|
}
|
|
55
|
-
const cacheKey = String(currentUser.id);
|
|
56
|
-
const cached = this.cache.get(cacheKey);
|
|
57
|
-
const now = Date.now();
|
|
58
|
-
if (cached && cached.expiresAt > now) {
|
|
59
|
-
cached.lastAccessedAt = now;
|
|
60
|
-
return cached.toolsMap;
|
|
61
|
-
}
|
|
62
|
-
if (cached) {
|
|
63
|
-
await this.closeEntry(cached);
|
|
64
|
-
this.cache.delete(cacheKey);
|
|
65
|
-
}
|
|
66
|
-
const connections = {};
|
|
67
|
-
for (const entry of entries) {
|
|
68
|
-
const rendered = await (0, import_options_renderer.renderMCPOptions)(entry, this.options.app, ctx);
|
|
69
|
-
connections[entry.name] = this.options.buildConnection(rendered);
|
|
70
|
-
}
|
|
71
|
-
const client = new import_mcp_adapters.MultiServerMCPClient(connections);
|
|
72
|
-
const initializedToolsMap = await client.initializeConnections();
|
|
73
|
-
const toolsMap = Object.fromEntries(
|
|
74
|
-
Object.entries(initializedToolsMap).map(([serverName, tools]) => [
|
|
75
|
-
serverName,
|
|
76
|
-
tools
|
|
77
|
-
])
|
|
78
|
-
);
|
|
79
|
-
this.cache.set(cacheKey, {
|
|
80
|
-
client,
|
|
81
|
-
toolsMap,
|
|
82
|
-
expiresAt: now + this.ttlMs,
|
|
83
|
-
lastAccessedAt: now
|
|
84
|
-
});
|
|
85
|
-
await this.evictOversized();
|
|
86
|
-
return toolsMap;
|
|
87
102
|
}
|
|
88
103
|
async clear() {
|
|
89
104
|
const entries = [...this.cache.values()];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/ai",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.11",
|
|
4
4
|
"description": "",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"@langchain/mcp-adapters": "1.1.3",
|
|
18
18
|
"@langchain/ollama": "1.2.7",
|
|
19
19
|
"@langchain/openai": "1.4.7",
|
|
20
|
-
"@nocobase/data-source-manager": "2.1.
|
|
21
|
-
"@nocobase/logger": "2.1.
|
|
22
|
-
"@nocobase/resourcer": "2.1.
|
|
23
|
-
"@nocobase/utils": "2.1.
|
|
20
|
+
"@nocobase/data-source-manager": "2.1.11",
|
|
21
|
+
"@nocobase/logger": "2.1.11",
|
|
22
|
+
"@nocobase/resourcer": "2.1.11",
|
|
23
|
+
"@nocobase/utils": "2.1.11",
|
|
24
24
|
"d3-dsv": "2",
|
|
25
25
|
"fast-glob": "^3.3.2",
|
|
26
26
|
"flexsearch": "^0.8.2",
|
|
@@ -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": "1ccf891d837e21089f65f84b892407b34a0a0cb9"
|
|
41
41
|
}
|
|
@@ -20,6 +20,9 @@ const mcpClientMock = vi.hoisted(() => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
async initializeConnections() {
|
|
23
|
+
if (Object.values(this.connections).some((connection) => connection?.failInitialize)) {
|
|
24
|
+
throw new Error('initialize failed');
|
|
25
|
+
}
|
|
23
26
|
return Object.fromEntries(
|
|
24
27
|
Object.keys(this.connections).map((serverName) => [
|
|
25
28
|
serverName,
|
|
@@ -84,6 +87,13 @@ describe('user-bound MCP clients', () => {
|
|
|
84
87
|
findOne: vi.fn(),
|
|
85
88
|
}),
|
|
86
89
|
},
|
|
90
|
+
request: {
|
|
91
|
+
headers: {
|
|
92
|
+
authorization: 'Bearer request-token',
|
|
93
|
+
'x-role': 'admin',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
getBearerToken: () => 'request-token',
|
|
87
97
|
}) as any;
|
|
88
98
|
|
|
89
99
|
beforeEach(() => {
|
|
@@ -139,6 +149,48 @@ describe('user-bound MCP clients', () => {
|
|
|
139
149
|
});
|
|
140
150
|
});
|
|
141
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
|
+
|
|
142
194
|
it('excludes user-bound records when rebuilding the shared client', async () => {
|
|
143
195
|
const manager = new DefaultMCPManager(createApp() as any) as any;
|
|
144
196
|
manager.listMCP = vi.fn().mockResolvedValue([]);
|
|
@@ -203,6 +255,28 @@ describe('user-bound MCP clients', () => {
|
|
|
203
255
|
expect(mcpClientMock.instances[0].connections.profile.url).toBe('https://mcp.example.test/11');
|
|
204
256
|
});
|
|
205
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
|
+
|
|
206
280
|
it('reuses cached user-bound tools and refreshes them after TTL', async () => {
|
|
207
281
|
vi.useFakeTimers();
|
|
208
282
|
vi.setSystemTime(0);
|
|
@@ -84,6 +84,16 @@ const stringifyArray = (value: unknown): string[] => {
|
|
|
84
84
|
return value.filter((item) => item != null).map((item) => String(item));
|
|
85
85
|
};
|
|
86
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
|
+
|
|
87
97
|
export const normalizeMCPOptions = (options: MCPOptions): MCPOptions => {
|
|
88
98
|
const normalized: MCPOptions = {
|
|
89
99
|
...options,
|
|
@@ -106,12 +116,15 @@ export async function renderMCPOptions(
|
|
|
106
116
|
ctx?: Context,
|
|
107
117
|
): Promise<MCPOptions> {
|
|
108
118
|
const currentUser = options.useUserContext ? await getCurrentUser(ctx, options) : undefined;
|
|
119
|
+
const request = options.useUserContext ? getRequestVariables(ctx) : emptyRequestVariables;
|
|
109
120
|
const variables = {
|
|
110
121
|
$env: app.environment?.getVariables?.() ?? {},
|
|
111
122
|
currentUser,
|
|
112
123
|
$user: currentUser,
|
|
124
|
+
request,
|
|
113
125
|
ctx: {
|
|
114
126
|
currentUser,
|
|
127
|
+
request,
|
|
115
128
|
},
|
|
116
129
|
};
|
|
117
130
|
|
|
@@ -46,52 +46,67 @@ export class UserContextMCPClientManager {
|
|
|
46
46
|
return {};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
let client: MultiServerMCPClient | null = null;
|
|
50
|
+
try {
|
|
51
|
+
this.evictExpired();
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
if (cached) {
|
|
69
|
+
await this.closeEntry(cached);
|
|
70
|
+
this.cache.delete(cacheKey);
|
|
71
|
+
}
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
async clear() {
|