@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.
@@ -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
- this.evictExpired();
51
- 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));
52
- if (!entries.length) {
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.10",
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.10",
21
- "@nocobase/logger": "2.1.10",
22
- "@nocobase/resourcer": "2.1.10",
23
- "@nocobase/utils": "2.1.10",
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": "65d5bc38d72d5a6fca8bcf9798a0b976e382cac9"
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
- this.evictExpired();
49
+ let client: MultiServerMCPClient | null = null;
50
+ try {
51
+ this.evictExpired();
50
52
 
51
- const entries = (await this.options.listEntries())
52
- .filter((entry) => entry.enabled !== false && entry.useUserContext === true && entry.transport !== 'stdio')
53
- .sort((left, right) => left.name.localeCompare(right.name));
54
- if (!entries.length) {
55
- return {};
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
- const cacheKey = String(currentUser.id);
59
- const cached = this.cache.get(cacheKey);
60
- const now = Date.now();
61
- if (cached && cached.expiresAt > now) {
62
- cached.lastAccessedAt = now;
63
- return cached.toolsMap;
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
- if (cached) {
67
- await this.closeEntry(cached);
68
- this.cache.delete(cacheKey);
69
- }
68
+ if (cached) {
69
+ await this.closeEntry(cached);
70
+ this.cache.delete(cacheKey);
71
+ }
70
72
 
71
- const connections: Record<string, MCPConnection> = {};
72
- for (const entry of entries) {
73
- const rendered = await renderMCPOptions(entry, this.options.app, ctx);
74
- connections[entry.name] = this.options.buildConnection(rendered);
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
- const client = new MultiServerMCPClient(connections);
78
- const initializedToolsMap = await client.initializeConnections();
79
- const toolsMap = Object.fromEntries(
80
- Object.entries(initializedToolsMap).map(([serverName, tools]) => [
81
- serverName,
82
- tools as StructuredToolInterface[],
83
- ]),
84
- );
85
-
86
- this.cache.set(cacheKey, {
87
- client,
88
- toolsMap,
89
- expiresAt: now + this.ttlMs,
90
- lastAccessedAt: now,
91
- });
92
- await this.evictOversized();
93
-
94
- return toolsMap;
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() {