@nocobase/ai 2.3.0-beta.23 → 2.3.0-beta.24

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.
@@ -0,0 +1,12 @@
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
+ export declare class MCPToolPermissionError extends Error {
10
+ readonly code: 'INVALID_PERMISSION' | 'TOOL_NOT_FOUND_OR_AMBIGUOUS';
11
+ constructor(code: 'INVALID_PERMISSION' | 'TOOL_NOT_FOUND_OR_AMBIGUOUS');
12
+ }
@@ -0,0 +1,45 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var errors_exports = {};
29
+ __export(errors_exports, {
30
+ MCPToolPermissionError: () => MCPToolPermissionError
31
+ });
32
+ module.exports = __toCommonJS(errors_exports);
33
+ const _MCPToolPermissionError = class _MCPToolPermissionError extends Error {
34
+ constructor(code) {
35
+ super(code);
36
+ this.code = code;
37
+ this.name = "MCPToolPermissionError";
38
+ }
39
+ };
40
+ __name(_MCPToolPermissionError, "MCPToolPermissionError");
41
+ let MCPToolPermissionError = _MCPToolPermissionError;
42
+ // Annotate the CommonJS export names for ESM import in node:
43
+ 0 && (module.exports = {
44
+ MCPToolPermissionError
45
+ });
@@ -17,7 +17,6 @@ export declare class DefaultMCPManager implements MCPManager {
17
17
  private mode;
18
18
  private client;
19
19
  private toolsMap;
20
- private toolsPermissionMap;
21
20
  private readonly userContextClientManager;
22
21
  constructor(app: any);
23
22
  init(): Promise<void>;
@@ -29,11 +28,11 @@ export declare class DefaultMCPManager implements MCPManager {
29
28
  rebuildClient(): Promise<void>;
30
29
  getClient(): MultiServerMCPClient | null;
31
30
  getMCPToolsProvider(): DynamicToolsProvider;
32
- private registerToolsFromMap;
33
- private ensureToolPermission;
31
+ private getActiveToolsMap;
32
+ private readToolPermissions;
33
+ private resolveToolPermission;
34
34
  listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>>;
35
- private formatMCPTools;
36
- updateMCPToolPermission(toolName: string, permission: Permission): Promise<void>;
35
+ updateMCPToolPermission(toolName: string, permission: Permission, ctx?: Context): Promise<void>;
37
36
  clearUserContextCache(): Promise<void>;
38
37
  testConnection(options: MCPOptions, ctx?: Context): Promise<MCPTestResult>;
39
38
  private buildMCPConnection;
@@ -47,3 +46,4 @@ export declare class DefaultMCPManager implements MCPManager {
47
46
  }
48
47
  export declare function defineMCP(options: MCPOptions): MCPOptions;
49
48
  export * from './types';
49
+ export * from './errors';
@@ -37,7 +37,9 @@ var import_utils = require("@nocobase/utils");
37
37
  var import_mcp_adapters = require("@langchain/mcp-adapters");
38
38
  var import_options_renderer = require("./options-renderer");
39
39
  var import_user_context_client_manager = require("./user-context-client-manager");
40
+ var import_errors = require("./errors");
40
41
  __reExport(mcp_manager_exports, require("./types"), module.exports);
42
+ __reExport(mcp_manager_exports, require("./errors"), module.exports);
41
43
  const _DefaultMCPManager = class _DefaultMCPManager {
42
44
  constructor(app) {
43
45
  this.app = app;
@@ -53,7 +55,6 @@ const _DefaultMCPManager = class _DefaultMCPManager {
53
55
  mode = "memory";
54
56
  client = null;
55
57
  toolsMap = {};
56
- toolsPermissionMap = {};
57
58
  userContextClientManager;
58
59
  async init() {
59
60
  if (this.mode === "memory") {
@@ -115,8 +116,8 @@ const _DefaultMCPManager = class _DefaultMCPManager {
115
116
  } catch (e) {
116
117
  }
117
118
  this.client = null;
118
- this.toolsMap = {};
119
119
  }
120
+ this.toolsMap = {};
120
121
  const entries = await this.listMCP({ enabled: true, useUserContext: false });
121
122
  if (entries.length === 0) {
122
123
  return;
@@ -129,12 +130,6 @@ const _DefaultMCPManager = class _DefaultMCPManager {
129
130
  const toolsMap = await this.client.initializeConnections();
130
131
  for (const [serverName, tools] of Object.entries(toolsMap)) {
131
132
  this.toolsMap[serverName] = tools;
132
- for (const tool of tools) {
133
- const toolName = `mcp-${serverName}-${tool.name}`;
134
- if (!(toolName in this.toolsPermissionMap)) {
135
- this.toolsPermissionMap[toolName] = tool.name.startsWith("get") ? "ALLOW" : "ASK";
136
- }
137
- }
138
133
  }
139
134
  }
140
135
  getClient() {
@@ -142,82 +137,110 @@ const _DefaultMCPManager = class _DefaultMCPManager {
142
137
  }
143
138
  getMCPToolsProvider() {
144
139
  return async (register, filter) => {
145
- for (const [serverName, tools] of Object.entries(this.toolsMap)) {
146
- this.registerToolsFromMap(register, serverName, tools);
147
- }
148
- if (!(filter == null ? void 0 : filter.ctx)) {
149
- return;
150
- }
151
- const userToolsMap = await this.userContextClientManager.getToolsMap(filter.ctx);
152
- for (const [serverName, tools] of Object.entries(userToolsMap)) {
153
- this.registerToolsFromMap(register, serverName, tools);
140
+ const toolsMap = await this.getActiveToolsMap(filter == null ? void 0 : filter.ctx);
141
+ const permissions = await this.readToolPermissions(Object.keys(toolsMap));
142
+ for (const [serverName, tools] of Object.entries(toolsMap)) {
143
+ for (const tool of tools) {
144
+ register.registerTools({
145
+ scope: "GENERAL",
146
+ from: "mcp",
147
+ defaultPermission: this.resolveToolPermission(permissions, serverName, tool.name),
148
+ introduction: { title: tool.name, about: tool.description },
149
+ definition: {
150
+ name: `mcp-${serverName}-${tool.name}`,
151
+ description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
152
+ schema: tool.schema
153
+ },
154
+ invoke: /* @__PURE__ */ __name(async (ctx, args) => {
155
+ const entry = await this.getMCP(serverName);
156
+ if (!(entry == null ? void 0 : entry.enabled)) {
157
+ throw new Error(ctx.t("MCP server is disabled or deleted", { ns: "@nocobase/plugin-ai" }));
158
+ }
159
+ try {
160
+ return await tool.invoke(args);
161
+ } catch (error) {
162
+ return {
163
+ status: "error",
164
+ content: error instanceof Error ? error.message : "Tool invocation failed"
165
+ };
166
+ }
167
+ }, "invoke")
168
+ });
169
+ }
154
170
  }
155
171
  };
156
172
  }
157
- registerToolsFromMap(register, serverName, tools) {
158
- for (const tool of tools) {
159
- const toolName = `mcp-${serverName}-${tool.name}`;
160
- this.ensureToolPermission(toolName, tool.name);
161
- const toolOptions = {
162
- scope: "GENERAL",
163
- from: "mcp",
164
- defaultPermission: this.toolsPermissionMap[toolName],
165
- introduction: {
166
- title: tool.name,
167
- about: tool.description
168
- },
169
- definition: {
170
- name: toolName,
171
- description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
172
- schema: tool.schema
173
- },
174
- invoke: /* @__PURE__ */ __name(async (_ctx, args) => {
175
- try {
176
- const result = await tool.invoke(args);
177
- return result;
178
- } catch (error) {
179
- return {
180
- status: "error",
181
- content: (error == null ? void 0 : error.message) || "Tool invocation failed"
182
- };
183
- }
184
- }, "invoke")
185
- };
186
- register.registerTools(toolOptions);
187
- }
173
+ async getActiveToolsMap(ctx) {
174
+ const entries = await this.listMCP({ enabled: true });
175
+ const sharedServers = new Set(entries.filter((entry) => !entry.useUserContext).map((entry) => entry.name));
176
+ const userServers = new Set(entries.filter((entry) => entry.useUserContext).map((entry) => entry.name));
177
+ const userToolsMap = ctx ? await this.userContextClientManager.getToolsMap(ctx) : {};
178
+ return {
179
+ ...Object.fromEntries(Object.entries(this.toolsMap).filter(([name]) => sharedServers.has(name))),
180
+ ...Object.fromEntries(Object.entries(userToolsMap).filter(([name]) => userServers.has(name)))
181
+ };
188
182
  }
189
- ensureToolPermission(toolName, rawToolName) {
190
- if (!(toolName in this.toolsPermissionMap)) {
191
- this.toolsPermissionMap[toolName] = rawToolName.startsWith("get") ? "ALLOW" : "ASK";
183
+ async readToolPermissions(serverNames) {
184
+ if (!serverNames.length) {
185
+ return /* @__PURE__ */ new Map();
192
186
  }
187
+ const records = await this.collectionManager.getCollection("aiMcpToolPermissions").model.findAll({
188
+ where: { serverName: { [import_database.Op.in]: serverNames } }
189
+ });
190
+ return new Map(
191
+ records.map((record) => [
192
+ JSON.stringify([record.get("serverName"), record.get("toolName")]),
193
+ record.get("permission") === "ALLOW" ? "ALLOW" : "ASK"
194
+ ])
195
+ );
193
196
  }
194
- async listMCPTools(ctx) {
195
- const toolsMap = {
196
- ...this.toolsMap,
197
- ...ctx ? await this.userContextClientManager.getToolsMap(ctx) : {}
198
- };
199
- return this.formatMCPTools(toolsMap);
197
+ resolveToolPermission(permissions, serverName, toolName) {
198
+ return permissions.get(JSON.stringify([serverName, toolName])) ?? (toolName.startsWith("get") ? "ALLOW" : "ASK");
200
199
  }
201
- formatMCPTools(toolsMap) {
200
+ async listMCPTools(ctx) {
201
+ const toolsMap = await this.getActiveToolsMap(ctx);
202
+ const permissions = await this.readToolPermissions(Object.keys(toolsMap));
202
203
  return Object.fromEntries(
203
204
  Object.entries(toolsMap).map(([serverName, tools]) => [
204
205
  serverName,
205
- tools.map((tool) => {
206
- const toolName = `mcp-${serverName}-${tool.name}`;
207
- this.ensureToolPermission(toolName, tool.name);
208
- return {
209
- name: toolName,
210
- title: tool.name,
211
- description: tool.description,
212
- serverName,
213
- permission: this.toolsPermissionMap[toolName] ?? "ASK"
214
- };
215
- })
206
+ tools.map((tool) => ({
207
+ name: `mcp-${serverName}-${tool.name}`,
208
+ title: tool.name,
209
+ description: tool.description,
210
+ serverName,
211
+ permission: this.resolveToolPermission(permissions, serverName, tool.name)
212
+ }))
216
213
  ])
217
214
  );
218
215
  }
219
- async updateMCPToolPermission(toolName, permission) {
220
- this.toolsPermissionMap[toolName] = permission;
216
+ async updateMCPToolPermission(toolName, permission, ctx) {
217
+ if (typeof toolName !== "string" || !toolName.trim() || !["ASK", "ALLOW"].includes(permission)) {
218
+ throw new import_errors.MCPToolPermissionError("INVALID_PERMISSION");
219
+ }
220
+ const matches = Object.values(await this.listMCPTools(ctx)).flat().filter((tool2) => tool2.name === toolName);
221
+ if (matches.length !== 1) {
222
+ throw new import_errors.MCPToolPermissionError("TOOL_NOT_FOUND_OR_AMBIGUOUS");
223
+ }
224
+ const tool = matches[0];
225
+ await this.sequelize.transaction({ type: import_database.Transaction.TYPES.IMMEDIATE }, async (transaction) => {
226
+ const server = await this.aiMcpClientsModel.findOne({
227
+ where: { name: tool.serverName, enabled: true },
228
+ transaction,
229
+ lock: transaction.LOCK.UPDATE
230
+ });
231
+ if (!server) {
232
+ throw new import_errors.MCPToolPermissionError("TOOL_NOT_FOUND_OR_AMBIGUOUS");
233
+ }
234
+ const permissionModel = this.collectionManager.getCollection("aiMcpToolPermissions").model;
235
+ await permissionModel.upsert(
236
+ { serverName: tool.serverName, toolName: tool.title, permission },
237
+ {
238
+ transaction,
239
+ // Sequelize expects physical column names here, including when underscored is enabled.
240
+ conflictFields: ["serverName", "toolName"].map((name) => permissionModel.getAttributes()[name].field || name)
241
+ }
242
+ );
243
+ });
221
244
  }
222
245
  async clearUserContextCache() {
223
246
  await this.userContextClientManager.clear();
@@ -386,5 +409,6 @@ __name(defineMCP, "defineMCP");
386
409
  0 && (module.exports = {
387
410
  DefaultMCPManager,
388
411
  defineMCP,
389
- ...require("./types")
412
+ ...require("./types"),
413
+ ...require("./errors")
390
414
  });
@@ -18,7 +18,7 @@ export interface MCPManager extends MCPRegistration {
18
18
  getClient(): MultiServerMCPClient | null;
19
19
  getMCPToolsProvider(): DynamicToolsProvider;
20
20
  listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>>;
21
- updateMCPToolPermission(toolName: string, permission: Permission): Promise<void>;
21
+ updateMCPToolPermission(toolName: string, permission: Permission, ctx?: Context): Promise<void>;
22
22
  clearUserContextCache(): Promise<void>;
23
23
  }
24
24
  export interface MCPRegistration {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/ai",
3
- "version": "2.3.0-beta.23",
3
+ "version": "2.3.0-beta.24",
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.3.0-beta.23",
21
- "@nocobase/logger": "2.3.0-beta.23",
22
- "@nocobase/resourcer": "2.3.0-beta.23",
23
- "@nocobase/utils": "2.3.0-beta.23",
20
+ "@nocobase/data-source-manager": "2.3.0-beta.24",
21
+ "@nocobase/logger": "2.3.0-beta.24",
22
+ "@nocobase/resourcer": "2.3.0-beta.24",
23
+ "@nocobase/utils": "2.3.0-beta.24",
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": "53ad17b1117bae431d21cddaac7af7da484967fc"
40
+ "gitHead": "9780899cbaa5fc4657ac9ea16da2ac99978d748d"
41
41
  }
@@ -0,0 +1,439 @@
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 { Transaction } from '@nocobase/database';
12
+ import { createMockServer, type MockServer } from '@nocobase/test';
13
+ import { mkdtemp, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { DefaultMCPManager, MCPToolPermissionError } from '../mcp-manager';
17
+ import { DefaultToolsManager } from '../tools-manager';
18
+ import type { Permission } from '../tools-manager/types';
19
+
20
+ const mocks = vi.hoisted(() => ({ failInitialize: false, invoke: vi.fn(async () => 'done') }));
21
+ vi.mock('@langchain/mcp-adapters', () => ({
22
+ MultiServerMCPClient: class {
23
+ constructor(private connections: Record<string, unknown>) {}
24
+ async initializeConnections() {
25
+ if (mocks.failInitialize) throw new Error('initialize failed');
26
+ return Object.fromEntries(
27
+ Object.keys(this.connections).map((name) => [
28
+ name,
29
+ ['get-data', 'write-data'].map((toolName) => ({
30
+ name: toolName,
31
+ description: toolName,
32
+ schema: {},
33
+ invoke: mocks.invoke,
34
+ })),
35
+ ]),
36
+ );
37
+ }
38
+ async close() {}
39
+ },
40
+ }));
41
+
42
+ describe.each([false, true])('global MCP tool permissions (underscored: %s)', (underscored) => {
43
+ let app: MockServer;
44
+ let storage: string;
45
+ let manager: DefaultMCPManager;
46
+ const clients = () => app.db.getRepository('aiMcpClients');
47
+ const permissions = () => app.db.getRepository('aiMcpToolPermissions');
48
+ const permissionModel = () => app.db.getModel('aiMcpToolPermissions');
49
+ const ctx = (id: number) => ({ state: { currentUser: { id } }, t: (key: string) => key }) as unknown as Context;
50
+ const toolsManager = (mcp = manager) => {
51
+ const tools = new DefaultToolsManager();
52
+ tools.registerDynamicTools(mcp.getMCPToolsProvider());
53
+ return tools;
54
+ };
55
+
56
+ beforeAll(async () => {
57
+ storage = await mkdtemp(join(tmpdir(), 'mcp-permissions-'));
58
+ vi.stubEnv('STORAGE_PATH', storage);
59
+ if ((process.env.DB_DIALECT || 'sqlite') === 'sqlite') {
60
+ vi.stubEnv('DB_SCHEMA', 'public');
61
+ }
62
+ app = await createMockServer({
63
+ plugins: ['nocobase'],
64
+ acl: true,
65
+ database: { storage: join(storage, 'test.sqlite'), underscored },
66
+ });
67
+ await app.pm.enable('ai');
68
+ });
69
+
70
+ beforeEach(async () => {
71
+ vi.restoreAllMocks();
72
+ mocks.invoke.mockClear();
73
+ mocks.failInitialize = false;
74
+ await clients().destroy({ filter: { name: { $ne: null } } });
75
+ await clients().create({
76
+ values: { name: 'test-server', transport: 'http', url: 'http://localhost/mcp' },
77
+ });
78
+ manager = new DefaultMCPManager(app);
79
+ await manager.init();
80
+ app.aiManager.mcpManager = manager;
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await manager?.getClient()?.close();
85
+ await manager?.clearUserContextCache();
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await app?.destroy();
90
+ vi.unstubAllEnvs();
91
+ await rm(storage, { recursive: true, force: true });
92
+ });
93
+
94
+ it('does not persist defaults during discovery, listing, registration or rebuilding', async () => {
95
+ expect((await manager.listMCPTools())['test-server'].map((tool) => tool.permission)).toEqual(['ALLOW', 'ASK']);
96
+ expect((await toolsManager().listTools()).map((tool) => tool.defaultPermission)).toEqual(['ALLOW', 'ASK']);
97
+ await manager.rebuildClient();
98
+ expect(await permissions().count()).toBe(0);
99
+ });
100
+
101
+ it('creates the permission collection on upgrade without changing existing MCP configuration', async () => {
102
+ await permissionModel().drop();
103
+ await app.upgrade();
104
+ expect(await permissions().count()).toBe(0);
105
+ expect(await app.aiManager.mcpManager.getMCP('test-server')).toMatchObject({
106
+ enabled: true,
107
+ transport: 'http',
108
+ url: 'http://localhost/mcp',
109
+ });
110
+ const upgraded = app.aiManager.mcpManager;
111
+ await upgraded.rebuildClient();
112
+ const attributes = permissionModel().getAttributes();
113
+ const indexFields = ['serverName', 'toolName'].map((name) => attributes[name].field || name);
114
+ expect(await app.db.sequelize.getQueryInterface().showIndex(permissionModel().getTableName())).toEqual(
115
+ expect.arrayContaining([
116
+ expect.objectContaining({
117
+ unique: true,
118
+ fields: indexFields.map((attribute) => expect.objectContaining({ attribute })),
119
+ }),
120
+ ]),
121
+ );
122
+ await upgraded.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
123
+ expect(await permissions().count()).toBe(1);
124
+ });
125
+
126
+ it('restores both reversed defaults on a new manager and preserves explicit values equal to defaults', async () => {
127
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
128
+ await manager.updateMCPToolPermission('mcp-test-server-write-data', 'ALLOW');
129
+ const restarted = new DefaultMCPManager(app);
130
+ await restarted.init();
131
+ expect((await restarted.listMCPTools())['test-server'].map((tool) => tool.permission)).toEqual(['ASK', 'ALLOW']);
132
+ expect((await toolsManager(restarted).listTools()).map((tool) => tool.defaultPermission)).toEqual(['ASK', 'ALLOW']);
133
+ await restarted.updateMCPToolPermission('mcp-test-server-get-data', 'ALLOW');
134
+ await restarted.updateMCPToolPermission('mcp-test-server-write-data', 'ASK');
135
+ expect(await permissions().count()).toBe(2);
136
+ expect((await manager.listMCPTools())['test-server'].map((tool) => tool.permission)).toEqual(['ALLOW', 'ASK']);
137
+ await restarted.getClient()?.close();
138
+ });
139
+
140
+ it('refreshes permissions across managers on every subsequent tool resolution', async () => {
141
+ const other = new DefaultMCPManager(app);
142
+ await other.init();
143
+ const tools = toolsManager(other);
144
+ expect((await tools.getTools('mcp-test-server-get-data')).defaultPermission).toBe('ALLOW');
145
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
146
+ expect((await tools.getTools('mcp-test-server-get-data')).defaultPermission).toBe('ASK');
147
+ expect((await other.listMCPTools())['test-server'][0].permission).toBe('ASK');
148
+ await other.getClient()?.close();
149
+ });
150
+
151
+ it('preserves permissions on disable and prevents stale tools from being listed or invoked', async () => {
152
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
153
+ const tools = toolsManager();
154
+ const oldTool = await tools.getTools('mcp-test-server-get-data');
155
+ await clients().update({ filterByTk: 'test-server', values: { enabled: false } });
156
+ expect(await manager.listMCPTools()).toEqual({});
157
+ expect(await tools.listTools()).toEqual([]);
158
+ await expect(oldTool.invoke(ctx(1), {}, { toolCallId: 'test', writer: vi.fn() })).rejects.toThrow(
159
+ 'MCP server is disabled or deleted',
160
+ );
161
+ expect(mocks.invoke).not.toHaveBeenCalled();
162
+ expect(await permissions().count()).toBe(1);
163
+ await clients().update({ filterByTk: 'test-server', values: { enabled: true } });
164
+ await manager.rebuildClient();
165
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ASK');
166
+ });
167
+
168
+ it('deletes permissions atomically and does not inherit them on same-name recreation', async () => {
169
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
170
+ const transaction = await app.db.sequelize.transaction();
171
+ await clients().destroy({ filterByTk: 'test-server', transaction });
172
+ expect(await permissions().count({ transaction })).toBe(0);
173
+ await transaction.rollback();
174
+ expect(await clients().count()).toBe(1);
175
+ expect(await permissions().count()).toBe(1);
176
+ await clients().destroy({ filterByTk: 'test-server' });
177
+ expect(await permissions().count()).toBe(0);
178
+ expect(await manager.listMCPTools()).toEqual({});
179
+ await clients().create({ values: { name: 'test-server', transport: 'http' } });
180
+ await manager.rebuildClient();
181
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ALLOW');
182
+ });
183
+
184
+ it('enforces cascading deletion even for a bulk model delete without individual hooks', async () => {
185
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
186
+ await app.db.getModel('aiMcpClients').destroy({ where: { name: 'test-server' }, hooks: false });
187
+ expect(await permissions().count()).toBe(0);
188
+ });
189
+
190
+ it('keeps the unique constraint for repeated and concurrent saves without overwriting other tools', async () => {
191
+ await Promise.all([
192
+ manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK'),
193
+ manager.updateMCPToolPermission('mcp-test-server-write-data', 'ALLOW'),
194
+ ]);
195
+ await Promise.all([
196
+ manager.updateMCPToolPermission('mcp-test-server-get-data', 'ALLOW'),
197
+ manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK'),
198
+ ]);
199
+ expect(await permissions().count()).toBe(2);
200
+ const stored = await permissions().findOne({ filter: { serverName: 'test-server', toolName: 'write-data' } });
201
+ expect(stored.get('permission')).toBe('ALLOW');
202
+ });
203
+
204
+ it('fails closed on database reads and never changes effective permission after a failed save', async () => {
205
+ const writeError = new Error('write failed');
206
+ const save = vi.spyOn(permissionModel(), 'upsert').mockRejectedValue(writeError);
207
+ await expect(manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK')).rejects.toBe(writeError);
208
+ expect(await permissions().count()).toBe(0);
209
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ALLOW');
210
+ save.mockRestore();
211
+ vi.spyOn(permissionModel(), 'findAll').mockRejectedValue(new Error('read failed'));
212
+ await expect(manager.listMCPTools()).rejects.toThrow('read failed');
213
+ await expect(toolsManager().listTools()).rejects.toThrow('read failed');
214
+ });
215
+
216
+ it('rejects invalid values, unknown tools and ambiguous public names without splitting hyphens', async () => {
217
+ for (const permission of ['DENY', '', null, {}, ['ASK']]) {
218
+ await expect(
219
+ manager.updateMCPToolPermission('mcp-test-server-get-data', permission as Permission),
220
+ ).rejects.toMatchObject({ name: 'MCPToolPermissionError', code: 'INVALID_PERMISSION' });
221
+ }
222
+ await expect(manager.updateMCPToolPermission('mcp-unknown-get-data', 'ASK')).rejects.toMatchObject({
223
+ name: 'MCPToolPermissionError',
224
+ code: 'TOOL_NOT_FOUND_OR_AMBIGUOUS',
225
+ });
226
+ await clients().create({ values: { name: 'test', transport: 'http' } });
227
+ Reflect.set(manager, 'toolsMap', {
228
+ 'test-server': [{ name: 'get-data' }],
229
+ test: [{ name: 'server-get-data' }],
230
+ });
231
+ await expect(manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK')).rejects.toMatchObject({
232
+ name: 'MCPToolPermissionError',
233
+ code: 'TOOL_NOT_FOUND_OR_AMBIGUOUS',
234
+ });
235
+ expect(await permissions().count()).toBe(0);
236
+ });
237
+
238
+ it('returns typed business errors without translating or assigning an HTTP status', async () => {
239
+ const context = ctx(1);
240
+ const requestTranslation = vi.spyOn(context, 't');
241
+ const appTranslation = vi.spyOn(app.i18n, 't');
242
+ for (const requestContext of [undefined, context]) {
243
+ const invalid = await manager.updateMCPToolPermission('', 'ASK', requestContext).catch((error: unknown) => error);
244
+ expect(invalid).toBeInstanceOf(MCPToolPermissionError);
245
+ expect(invalid).toHaveProperty('code', 'INVALID_PERMISSION');
246
+ expect(invalid).not.toHaveProperty('status');
247
+ const unknown = await manager
248
+ .updateMCPToolPermission('mcp-unknown-get-data', 'ASK', requestContext)
249
+ .catch((error: unknown) => error);
250
+ expect(unknown).toBeInstanceOf(MCPToolPermissionError);
251
+ expect(unknown).toHaveProperty('code', 'TOOL_NOT_FOUND_OR_AMBIGUOUS');
252
+ expect(unknown).not.toHaveProperty('status');
253
+ }
254
+ expect(requestTranslation).not.toHaveBeenCalled();
255
+ expect(appTranslation).not.toHaveBeenCalled();
256
+ expect(await permissions().count()).toBe(0);
257
+ });
258
+
259
+ it('returns a business error if the server disappears before the transactional lookup', async () => {
260
+ vi.spyOn(app.db.getModel('aiMcpClients'), 'findOne').mockResolvedValueOnce(null);
261
+ await expect(manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK')).rejects.toMatchObject({
262
+ name: 'MCPToolPermissionError',
263
+ code: 'TOOL_NOT_FOUND_OR_AMBIGUOUS',
264
+ });
265
+ expect(await permissions().count()).toBe(0);
266
+ });
267
+
268
+ it('shares global permissions between users and restores user-context tools after manager recreation', async () => {
269
+ await clients().update({ filterByTk: 'test-server', values: { useUserContext: true } });
270
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK', ctx(1));
271
+ expect((await manager.listMCPTools(ctx(2)))['test-server'][0].permission).toBe('ASK');
272
+ const restarted = new DefaultMCPManager(app);
273
+ await restarted.init();
274
+ expect((await toolsManager(restarted).listTools({ ctx: ctx(2) }))[0].defaultPermission).toBe('ASK');
275
+ await restarted.updateMCPToolPermission('mcp-test-server-write-data', 'ALLOW', ctx(2));
276
+ expect((await manager.listMCPTools(ctx(1)))['test-server'][1].permission).toBe('ALLOW');
277
+ await clients().update({ filterByTk: 'test-server', values: { enabled: false } });
278
+ expect(await manager.listMCPTools(ctx(1))).toEqual({});
279
+ await restarted.clearUserContextCache();
280
+ });
281
+
282
+ it('does not overwrite explicit permissions during file configuration sync or tool disappearance', async () => {
283
+ await manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK');
284
+ await manager.registerMCP({
285
+ 'test-server': { transport: 'http', url: 'http://localhost/mcp', fromFile: true },
286
+ });
287
+ await manager.registerMCP({
288
+ 'test-server': { transport: 'http', url: 'http://localhost/mcp', fromFile: true },
289
+ });
290
+ await manager.rebuildClient();
291
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ASK');
292
+ Reflect.set(manager, 'toolsMap', { 'test-server': [] });
293
+ await manager.listMCPTools();
294
+ expect(await permissions().count()).toBe(1);
295
+ });
296
+
297
+ it('batches permission reads rather than issuing one query per tool', async () => {
298
+ const read = vi.spyOn(permissionModel(), 'findAll');
299
+ await manager.listMCPTools();
300
+ expect(read).toHaveBeenCalledTimes(1);
301
+ read.mockClear();
302
+ await toolsManager().listTools();
303
+ expect(read).toHaveBeenCalledTimes(1);
304
+ });
305
+
306
+ it('does not save a permission when user-context tool discovery fails', async () => {
307
+ await clients().update({ filterByTk: 'test-server', values: { useUserContext: true } });
308
+ mocks.failInitialize = true;
309
+ await expect(manager.updateMCPToolPermission('mcp-test-server-get-data', 'ASK', ctx(1))).rejects.toMatchObject({
310
+ name: 'MCPToolPermissionError',
311
+ code: 'TOOL_NOT_FOUND_OR_AMBIGUOUS',
312
+ });
313
+ expect(await permissions().count()).toBe(0);
314
+ });
315
+
316
+ it('allows permissions for deployment-managed stdio tools without changing their connection', async () => {
317
+ await clients().update({
318
+ filterByTk: 'test-server',
319
+ values: { transport: 'stdio', command: 'trusted', fromFile: true },
320
+ });
321
+ const agent = await app.agent().login(1);
322
+ const response = await agent.resource('aiMcpClients').updateToolPermission({
323
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ASK', command: 'untrusted' },
324
+ });
325
+ expect(response.status).toBe(200);
326
+ expect((await manager.getMCP('test-server')).command).toBe('trusted');
327
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ASK');
328
+ });
329
+
330
+ it('persists administrator API changes, protects ACL and does not change file-managed connection fields', async () => {
331
+ await clients().update({ filterByTk: 'test-server', values: { fromFile: true } });
332
+ const admin = await app.agent().login(1);
333
+ const resource = admin.resource('aiMcpClients');
334
+ const response = await resource.updateToolPermission({
335
+ values: {
336
+ toolName: 'mcp-test-server-get-data',
337
+ permission: 'ASK',
338
+ command: 'untrusted',
339
+ url: 'http://attacker.invalid',
340
+ fromFile: false,
341
+ },
342
+ });
343
+ expect(response.status).toBe(200);
344
+ expect(response.body.data).toEqual({ success: true });
345
+ const list = await resource.listTools();
346
+ expect(list.body.data['test-server'][0].permission).toBe('ASK');
347
+ expect(await clients().count()).toBe(1);
348
+ const server = await clients().findOne({ filterByTk: 'test-server' });
349
+ expect(server.get('fromFile')).toBe(true);
350
+ expect(server.get('url')).toBe('http://localhost/mcp');
351
+ expect(server.get('command')).toBeFalsy();
352
+
353
+ const user = await app.db.getRepository('users').create({ values: { username: 'mcp-permission-member' } });
354
+ const member = await app.agent().login(user.id);
355
+ const denied = await member.resource('aiMcpClients').updateToolPermission({
356
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ALLOW' },
357
+ });
358
+ expect(denied.status).toBe(403);
359
+ const guest = await app
360
+ .agent()
361
+ .resource('aiMcpClients')
362
+ .updateToolPermission({
363
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ALLOW' },
364
+ });
365
+ expect([401, 403]).toContain(guest.status);
366
+ const generic = await member.resource('aiMcpToolPermissions').create({
367
+ values: { serverName: 'test-server', toolName: 'write-data', permission: 'ALLOW' },
368
+ });
369
+ expect(generic.status).toBe(403);
370
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ASK');
371
+ expect(await permissions().count()).toBe(1);
372
+ });
373
+
374
+ it('rejects malformed API input and localizes errors without writing records', async () => {
375
+ const agent = await app.agent().login(1);
376
+ for (const values of [
377
+ {},
378
+ { toolName: '', permission: 'ALLOW' },
379
+ { toolName: ' ', permission: 'ALLOW' },
380
+ { toolName: {}, permission: 'ALLOW' },
381
+ { toolName: 'mcp-test-server-get-data', permission: 'DENY' },
382
+ { toolName: 'mcp-test-server-get-data', permission: ['ASK'] },
383
+ ]) {
384
+ expect((await agent.resource('aiMcpClients').updateToolPermission({ values })).status).toBe(400);
385
+ }
386
+ const unknown = await agent.resource('aiMcpClients').updateToolPermission({
387
+ values: { toolName: 'mcp-unknown-get-data', permission: 'ALLOW' },
388
+ });
389
+ expect(unknown.status).toBe(400);
390
+ expect(unknown.body.errors[0].message).toBe('MCP tool not found or ambiguous');
391
+ const prefix = (app.resourcer.options.prefix || '').replace(/\/$/, '');
392
+ const response = await agent.post(`${prefix}/aiMcpClients:updateToolPermission`).set('X-Locale', 'zh-CN').send({});
393
+ expect(response.status).toBe(400);
394
+ expect(response.body.errors[0].message).toBe('MCP 工具权限配置无效');
395
+ expect(await permissions().count()).toBe(0);
396
+ });
397
+
398
+ it.each([
399
+ ['INVALID_PERMISSION', 'MCP 工具权限配置无效'],
400
+ ['TOOL_NOT_FOUND_OR_AMBIGUOUS', 'MCP 工具不存在或标识不唯一'],
401
+ ] as const)('translates the %s manager business error into an HTTP 400', async (code, message) => {
402
+ vi.spyOn(manager, 'updateMCPToolPermission').mockRejectedValue(new MCPToolPermissionError(code));
403
+ const agent = await app.agent().login(1);
404
+ const prefix = (app.resourcer.options.prefix || '').replace(/\/$/, '');
405
+ const response = await agent
406
+ .post(`${prefix}/aiMcpClients:updateToolPermission`)
407
+ .set('X-Locale', 'zh-CN')
408
+ .send({ toolName: 'mcp-test-server-get-data', permission: 'ASK' });
409
+ expect(response.status).toBe(400);
410
+ expect(response.body.errors[0].message).toBe(message);
411
+ expect(await permissions().count()).toBe(0);
412
+ });
413
+
414
+ it('returns an API failure after a database write or commit failure instead of reporting success', async () => {
415
+ const agent = await app.agent().login(1);
416
+ const writeError = Object.assign(new Error('write failed'), { code: 'INVALID_PERMISSION' });
417
+ const save = vi.spyOn(permissionModel(), 'upsert').mockRejectedValue(writeError);
418
+ const response = await agent.resource('aiMcpClients').updateToolPermission({
419
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ASK' },
420
+ });
421
+ expect(response.status).toBe(500);
422
+ save.mockRestore();
423
+ vi.spyOn(Transaction.prototype, 'commit').mockImplementationOnce(async function (this: Transaction) {
424
+ await this.rollback();
425
+ throw new Error('commit failed');
426
+ });
427
+ const commitFailure = await agent.resource('aiMcpClients').updateToolPermission({
428
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ASK' },
429
+ });
430
+ expect(commitFailure.status).toBe(500);
431
+ expect(await permissions().count()).toBe(0);
432
+ expect((await manager.listMCPTools())['test-server'][0].permission).toBe('ALLOW');
433
+ vi.spyOn(permissionModel(), 'findAll').mockRejectedValue(new Error('read failed'));
434
+ const readFailure = await agent.resource('aiMcpClients').updateToolPermission({
435
+ values: { toolName: 'mcp-test-server-get-data', permission: 'ASK' },
436
+ });
437
+ expect(readFailure.status).toBe(500);
438
+ });
439
+ });
@@ -55,6 +55,11 @@ import { UserContextMCPClientManager } from '../mcp-manager/user-context-client-
55
55
 
56
56
  describe('user-bound MCP clients', () => {
57
57
  const createApp = () => ({
58
+ mainDataSource: {
59
+ collectionManager: {
60
+ getCollection: () => ({ model: { findAll: vi.fn().mockResolvedValue([]) } }),
61
+ },
62
+ },
58
63
  environment: {
59
64
  getVariables: () => ({
60
65
  MCP_HOST: 'mcp.example.test',
@@ -34,6 +34,8 @@ describe('MCP loader test cases', () => {
34
34
  beforeEach(async () => {
35
35
  storage = await mkdtemp(join(tmpdir(), 'mcp-persistence-'));
36
36
  vi.stubEnv('STORAGE_PATH', storage);
37
+ // SQLite fixtures must not inherit a PostgreSQL schema from the CI matrix.
38
+ vi.stubEnv('DB_SCHEMA', 'public');
37
39
  filePath = join(storage, 'ai', 'mcp', 'servers.json');
38
40
  await mkdir(join(storage, 'ai', 'mcp'), { recursive: true });
39
41
  app = await createMockServer({
@@ -165,6 +167,7 @@ describe('MCP loader test cases', () => {
165
167
  });
166
168
 
167
169
  it('should expose cached mcp tools and allow updating permissions', async () => {
170
+ await app.db.getRepository('aiMcpClients').create({ values: { name: 'weather', transport: 'http' } });
168
171
  Reflect.set(mcpManager, 'toolsMap', {
169
172
  weather: [
170
173
  {
@@ -0,0 +1,15 @@
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
+ export class MCPToolPermissionError extends Error {
11
+ constructor(readonly code: 'INVALID_PERMISSION' | 'TOOL_NOT_FOUND_OR_AMBIGUOUS') {
12
+ super(code);
13
+ this.name = 'MCPToolPermissionError';
14
+ }
15
+ }
@@ -8,15 +8,16 @@
8
8
  */
9
9
 
10
10
  import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
11
- import { Op } from '@nocobase/database';
11
+ import { Op, Transaction } from '@nocobase/database';
12
12
  import { Registry } from '@nocobase/utils';
13
13
  import { MultiServerMCPClient, StdioConnection, StreamableHTTPConnection } from '@langchain/mcp-adapters';
14
14
  import { StructuredToolInterface } from '@langchain/core/tools';
15
15
  import { MCPEntry, MCPFilter, MCPManager, MCPOptions, MCPTestResult, MCPToolEntry } from './types';
16
- import type { DynamicToolsProvider, Permission, ToolsRegistration, ToolsOptions } from '../tools-manager/types';
16
+ import type { DynamicToolsProvider, Permission, ToolsRegistration } from '../tools-manager/types';
17
17
  import type { Context } from '@nocobase/actions';
18
18
  import { normalizeMCPOptions, renderMCPOptions } from './options-renderer';
19
19
  import { UserContextMCPClientManager } from './user-context-client-manager';
20
+ import { MCPToolPermissionError } from './errors';
20
21
 
21
22
  export class DefaultMCPManager implements MCPManager {
22
23
  private readonly mcpRegistry = new Registry<MCPEntry>({ override: true });
@@ -24,7 +25,6 @@ export class DefaultMCPManager implements MCPManager {
24
25
  private mode = 'memory';
25
26
  private client: MultiServerMCPClient | null = null;
26
27
  private toolsMap: Record<string, StructuredToolInterface[]> = {};
27
- private toolsPermissionMap: Record<string, Permission> = {};
28
28
  private readonly userContextClientManager: UserContextMCPClientManager;
29
29
 
30
30
  constructor(private readonly app: any) {
@@ -102,8 +102,8 @@ export class DefaultMCPManager implements MCPManager {
102
102
  // Ignore close errors
103
103
  }
104
104
  this.client = null;
105
- this.toolsMap = {};
106
105
  }
106
+ this.toolsMap = {};
107
107
 
108
108
  // Get all enabled MCP entries
109
109
  const entries = await this.listMCP({ enabled: true, useUserContext: false });
@@ -125,12 +125,6 @@ export class DefaultMCPManager implements MCPManager {
125
125
  // Cache tools for each server
126
126
  for (const [serverName, tools] of Object.entries(toolsMap)) {
127
127
  this.toolsMap[serverName] = tools as StructuredToolInterface[];
128
- for (const tool of tools as StructuredToolInterface[]) {
129
- const toolName = `mcp-${serverName}-${tool.name}`;
130
- if (!(toolName in this.toolsPermissionMap)) {
131
- this.toolsPermissionMap[toolName] = tool.name.startsWith('get') ? 'ALLOW' : 'ASK';
132
- }
133
- }
134
128
  }
135
129
  }
136
130
 
@@ -140,95 +134,123 @@ export class DefaultMCPManager implements MCPManager {
140
134
 
141
135
  getMCPToolsProvider(): DynamicToolsProvider {
142
136
  return async (register: ToolsRegistration, filter): Promise<void> => {
143
- // Use cached tools from rebuildClient
144
- for (const [serverName, tools] of Object.entries(this.toolsMap)) {
145
- this.registerToolsFromMap(register, serverName, tools);
146
- }
147
-
148
- if (!filter?.ctx) {
149
- return;
150
- }
151
-
152
- const userToolsMap = await this.userContextClientManager.getToolsMap(filter.ctx);
153
- for (const [serverName, tools] of Object.entries(userToolsMap)) {
154
- this.registerToolsFromMap(register, serverName, tools);
137
+ const toolsMap = await this.getActiveToolsMap(filter?.ctx);
138
+ const permissions = await this.readToolPermissions(Object.keys(toolsMap));
139
+ for (const [serverName, tools] of Object.entries(toolsMap)) {
140
+ for (const tool of tools) {
141
+ register.registerTools({
142
+ scope: 'GENERAL',
143
+ from: 'mcp',
144
+ defaultPermission: this.resolveToolPermission(permissions, serverName, tool.name),
145
+ introduction: { title: tool.name, about: tool.description },
146
+ definition: {
147
+ name: `mcp-${serverName}-${tool.name}`,
148
+ description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
149
+ schema: tool.schema,
150
+ },
151
+ invoke: async (ctx: Context, args: Parameters<typeof tool.invoke>[0]) => {
152
+ const entry = await this.getMCP(serverName);
153
+ if (!entry?.enabled) {
154
+ throw new Error(ctx.t('MCP server is disabled or deleted', { ns: '@nocobase/plugin-ai' }));
155
+ }
156
+ try {
157
+ return await tool.invoke(args);
158
+ } catch (error: unknown) {
159
+ return {
160
+ status: 'error' as const,
161
+ content: error instanceof Error ? error.message : 'Tool invocation failed',
162
+ };
163
+ }
164
+ },
165
+ });
166
+ }
155
167
  }
156
168
  };
157
169
  }
158
170
 
159
- private registerToolsFromMap(
160
- register: ToolsRegistration,
161
- serverName: string,
162
- tools: StructuredToolInterface[],
163
- ): void {
164
- for (const tool of tools) {
165
- const toolName = `mcp-${serverName}-${tool.name}`;
166
- this.ensureToolPermission(toolName, tool.name);
167
- const toolOptions: ToolsOptions = {
168
- scope: 'GENERAL',
169
- from: 'mcp',
170
- defaultPermission: this.toolsPermissionMap[toolName],
171
- introduction: {
172
- title: tool.name,
173
- about: tool.description,
174
- },
175
- definition: {
176
- name: toolName,
177
- description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
178
- schema: tool.schema,
179
- },
180
- invoke: async (_ctx: Context, args: any) => {
181
- try {
182
- const result = await tool.invoke(args);
183
- return result;
184
- } catch (error: any) {
185
- return {
186
- status: 'error' as const,
187
- content: error?.message || 'Tool invocation failed',
188
- };
189
- }
190
- },
191
- };
192
- register.registerTools(toolOptions);
193
- }
171
+ private async getActiveToolsMap(ctx?: Context): Promise<Record<string, StructuredToolInterface[]>> {
172
+ // Connection caches are not configuration authorities, including on other application instances.
173
+ const entries = await this.listMCP({ enabled: true });
174
+ const sharedServers = new Set(entries.filter((entry) => !entry.useUserContext).map((entry) => entry.name));
175
+ const userServers = new Set(entries.filter((entry) => entry.useUserContext).map((entry) => entry.name));
176
+ const userToolsMap = ctx ? await this.userContextClientManager.getToolsMap(ctx) : {};
177
+ return {
178
+ ...Object.fromEntries(Object.entries(this.toolsMap).filter(([name]) => sharedServers.has(name))),
179
+ ...Object.fromEntries(Object.entries(userToolsMap).filter(([name]) => userServers.has(name))),
180
+ };
194
181
  }
195
182
 
196
- private ensureToolPermission(toolName: string, rawToolName: string) {
197
- if (!(toolName in this.toolsPermissionMap)) {
198
- this.toolsPermissionMap[toolName] = rawToolName.startsWith('get') ? 'ALLOW' : 'ASK';
183
+ private async readToolPermissions(serverNames: string[]): Promise<Map<string, Permission>> {
184
+ if (!serverNames.length) {
185
+ return new Map();
199
186
  }
187
+ const records = await this.collectionManager.getCollection('aiMcpToolPermissions').model.findAll({
188
+ where: { serverName: { [Op.in]: serverNames } },
189
+ });
190
+ return new Map(
191
+ records.map((record) => [
192
+ JSON.stringify([record.get('serverName'), record.get('toolName')]),
193
+ record.get('permission') === 'ALLOW' ? 'ALLOW' : 'ASK',
194
+ ]),
195
+ );
200
196
  }
201
197
 
202
- async listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>> {
203
- const toolsMap = {
204
- ...this.toolsMap,
205
- ...(ctx ? await this.userContextClientManager.getToolsMap(ctx) : {}),
206
- };
207
-
208
- return this.formatMCPTools(toolsMap);
198
+ private resolveToolPermission(
199
+ permissions: Map<string, Permission>,
200
+ serverName: string,
201
+ toolName: string,
202
+ ): Permission {
203
+ return permissions.get(JSON.stringify([serverName, toolName])) ?? (toolName.startsWith('get') ? 'ALLOW' : 'ASK');
209
204
  }
210
205
 
211
- private formatMCPTools(toolsMap: Record<string, StructuredToolInterface[]>): Record<string, MCPToolEntry[]> {
206
+ async listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>> {
207
+ const toolsMap = await this.getActiveToolsMap(ctx);
208
+ const permissions = await this.readToolPermissions(Object.keys(toolsMap));
212
209
  return Object.fromEntries(
213
210
  Object.entries(toolsMap).map(([serverName, tools]) => [
214
211
  serverName,
215
- tools.map((tool) => {
216
- const toolName = `mcp-${serverName}-${tool.name}`;
217
- this.ensureToolPermission(toolName, tool.name);
218
- return {
219
- name: toolName,
220
- title: tool.name,
221
- description: tool.description,
222
- serverName,
223
- permission: this.toolsPermissionMap[toolName] ?? 'ASK',
224
- };
225
- }),
212
+ tools.map((tool) => ({
213
+ name: `mcp-${serverName}-${tool.name}`,
214
+ title: tool.name,
215
+ description: tool.description,
216
+ serverName,
217
+ permission: this.resolveToolPermission(permissions, serverName, tool.name),
218
+ })),
226
219
  ]),
227
220
  );
228
221
  }
229
222
 
230
- async updateMCPToolPermission(toolName: string, permission: Permission): Promise<void> {
231
- this.toolsPermissionMap[toolName] = permission;
223
+ async updateMCPToolPermission(toolName: string, permission: Permission, ctx?: Context): Promise<void> {
224
+ if (typeof toolName !== 'string' || !toolName.trim() || !['ASK', 'ALLOW'].includes(permission)) {
225
+ throw new MCPToolPermissionError('INVALID_PERMISSION');
226
+ }
227
+ // Resolve from metadata, never by splitting the public name: both identifiers may contain hyphens.
228
+ const matches = Object.values(await this.listMCPTools(ctx))
229
+ .flat()
230
+ .filter((tool) => tool.name === toolName);
231
+ if (matches.length !== 1) {
232
+ throw new MCPToolPermissionError('TOOL_NOT_FOUND_OR_AMBIGUOUS');
233
+ }
234
+ const tool = matches[0];
235
+ await this.sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE }, async (transaction) => {
236
+ const server = await this.aiMcpClientsModel.findOne({
237
+ where: { name: tool.serverName, enabled: true },
238
+ transaction,
239
+ lock: transaction.LOCK.UPDATE,
240
+ });
241
+ if (!server) {
242
+ throw new MCPToolPermissionError('TOOL_NOT_FOUND_OR_AMBIGUOUS');
243
+ }
244
+ const permissionModel = this.collectionManager.getCollection('aiMcpToolPermissions').model;
245
+ await permissionModel.upsert(
246
+ { serverName: tool.serverName, toolName: tool.title, permission },
247
+ {
248
+ transaction,
249
+ // Sequelize expects physical column names here, including when underscored is enabled.
250
+ conflictFields: ['serverName', 'toolName'].map((name) => permissionModel.getAttributes()[name].field || name),
251
+ },
252
+ );
253
+ });
232
254
  }
233
255
 
234
256
  async clearUserContextCache(): Promise<void> {
@@ -424,3 +446,4 @@ export function defineMCP(options: MCPOptions) {
424
446
  }
425
447
 
426
448
  export * from './types';
449
+ export * from './errors';
@@ -20,7 +20,7 @@ export interface MCPManager extends MCPRegistration {
20
20
  getClient(): MultiServerMCPClient | null;
21
21
  getMCPToolsProvider(): DynamicToolsProvider;
22
22
  listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>>;
23
- updateMCPToolPermission(toolName: string, permission: Permission): Promise<void>;
23
+ updateMCPToolPermission(toolName: string, permission: Permission, ctx?: Context): Promise<void>;
24
24
  clearUserContextCache(): Promise<void>;
25
25
  }
26
26