@nocobase/ai 2.4.0-alpha.16 → 2.4.0-alpha.18

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.
@@ -8,44 +8,74 @@
8
8
  */
9
9
 
10
10
  import { createMockServer, MockServer } from '@nocobase/test';
11
- import path from 'path';
11
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
12
14
  import { AIManager } from '../ai-manager';
13
15
  import { MCPLoader } from '../loader';
14
- import { MCPManager } from '../mcp-manager';
16
+ import { DefaultMCPManager, MCPManager } from '../mcp-manager';
17
+ vi.mock('@langchain/mcp-adapters', () => ({
18
+ MultiServerMCPClient: class {
19
+ async initializeConnections() {
20
+ return {};
21
+ }
22
+ async close() {}
23
+ },
24
+ }));
15
25
 
16
26
  describe('MCP loader test cases', () => {
17
- const basePath = path.resolve(__dirname, 'resource', 'ai');
27
+ let storage: string;
28
+ let filePath: string;
18
29
  let app: MockServer;
19
30
  let aiManager: AIManager;
20
31
  let mcpManager: MCPManager;
21
32
  let loader: MCPLoader;
22
33
 
23
34
  beforeEach(async () => {
35
+ storage = await mkdtemp(join(tmpdir(), 'mcp-persistence-'));
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');
39
+ filePath = join(storage, 'ai', 'mcp', 'servers.json');
40
+ await mkdir(join(storage, 'ai', 'mcp'), { recursive: true });
24
41
  app = await createMockServer({
25
42
  plugins: ['nocobase'],
43
+ database: { dialect: 'sqlite', storage: join(storage, 'test.sqlite') },
26
44
  });
27
45
  await app.pm.enable('ai');
28
46
  aiManager = app.aiManager;
29
47
  mcpManager = aiManager.mcpManager;
30
- loader = new MCPLoader(aiManager, {
31
- scan: {
32
- basePath,
33
- pattern: ['**/mcp/*.ts', '!**/mcp/*.d.ts'],
34
- },
35
- });
48
+ loader = new MCPLoader(mcpManager);
36
49
  });
37
50
 
38
51
  afterEach(async () => {
39
52
  await app.destroy();
53
+ vi.unstubAllEnvs();
54
+ await rm(storage, { recursive: true, force: true });
40
55
  });
41
56
 
42
- it('should load mcp definitions in mcp root directory', async () => {
57
+ it('loads JSON deployment definitions into the database', async () => {
58
+ await writeFile(
59
+ filePath,
60
+ JSON.stringify([
61
+ {
62
+ name: 'weather',
63
+ transport: 'http',
64
+ url: 'http://localhost:8123/mcp',
65
+ headers: { Authorization: 'Bearer test-token' },
66
+ env: { MCP_ENV: 'test' },
67
+ args: ['--foo'],
68
+ restart: { enabled: true },
69
+ },
70
+ ]),
71
+ );
43
72
  await loader.load();
44
73
 
45
74
  const entry = await mcpManager.getMCP('weather');
46
75
  expect(entry).toBeDefined();
47
76
  expect(entry.name).toBe('weather');
48
77
  expect(entry.enabled).toBe(true);
78
+ expect(entry.fromFile).toBe(true);
49
79
  expect(entry.transport).toBe('http');
50
80
  expect(entry.url).toBe('http://localhost:8123/mcp');
51
81
  expect(entry.headers).toEqual({
@@ -63,9 +93,82 @@ describe('MCP loader test cases', () => {
63
93
  expect(enabledEntries.map((item) => item.name)).toEqual(['weather']);
64
94
  });
65
95
 
96
+ it('preserves disabled state and management through updates, repeated loads and removed entries', async () => {
97
+ const definitions = [
98
+ { name: 'local', transport: 'stdio', command: 'node', useUserContext: true },
99
+ { name: 'remote', transport: 'http', url: 'http://127.0.0.1/old', useUserContext: true },
100
+ ];
101
+ await writeFile(filePath, JSON.stringify(definitions));
102
+ await loader.load();
103
+ expect(await mcpManager.getMCP('local')).toMatchObject({ enabled: true, fromFile: true, useUserContext: false });
104
+ const repo = app.db.getRepository('aiMcpClients');
105
+ await repo.update({ filterByTk: 'remote', values: { enabled: false } });
106
+ definitions[1].url = 'http://127.0.0.1/new';
107
+ await writeFile(filePath, JSON.stringify(definitions));
108
+ await loader.load();
109
+ await loader.load();
110
+ expect(await repo.count()).toBe(2);
111
+ expect(await mcpManager.getMCP('remote')).toMatchObject({
112
+ enabled: false,
113
+ fromFile: true,
114
+ url: definitions[1].url,
115
+ useUserContext: true,
116
+ });
117
+ await writeFile(filePath, '[]');
118
+ await loader.load();
119
+ await rm(filePath);
120
+ await loader.load();
121
+ await mcpManager.registerMCP({ remote: { transport: 'http', url: 'http://127.0.0.1/trusted', fromFile: false } });
122
+ expect(await mcpManager.getMCP('remote')).toMatchObject({ enabled: false, fromFile: true });
123
+ expect(await repo.count()).toBe(2);
124
+ });
125
+ it('validates before memory registration and persists before initializing connections', async () => {
126
+ const manager = new DefaultMCPManager(app);
127
+ const memoryLoader = new MCPLoader(manager);
128
+ await manager.registerMCP({ local: { transport: 'stdio', command: 'old' } });
129
+ await writeFile(filePath, JSON.stringify([{ name: 'local', transport: 'stdio', command: 'node' }]));
130
+ await memoryLoader.load();
131
+ await memoryLoader.load();
132
+ await manager.registerMCP({ local: { transport: 'stdio', command: 'trusted', fromFile: false } });
133
+ expect(await app.db.getRepository('aiMcpClients').count()).toBe(0);
134
+ const rebuild = vi.spyOn(manager, 'rebuildClient').mockImplementation(async () => {
135
+ expect(await manager.getMCP('local')).toMatchObject({ command: 'trusted', fromFile: true, enabled: true });
136
+ });
137
+ await manager.init();
138
+ expect(rebuild).toHaveBeenCalledTimes(1);
139
+ expect(await app.db.getRepository('aiMcpClients').count()).toBe(1);
140
+ });
141
+
142
+ it('loads deployment configuration through the actual afterStart lifecycle', async () => {
143
+ await writeFile(
144
+ filePath,
145
+ JSON.stringify([{ name: 'startup', transport: 'http', url: 'http://127.0.0.1/startup' }]),
146
+ );
147
+ await app.stop();
148
+ await app.start();
149
+ expect(await app.aiManager.mcpManager.getMCP('startup')).toMatchObject({ enabled: true, fromFile: true });
150
+ });
151
+
152
+ it('adds the source column on upgrade without changing existing enabled state', async () => {
153
+ const collection = app.db.getCollection('aiMcpClients');
154
+ await app.db.getRepository('aiMcpClients').create({
155
+ values: {
156
+ name: 'legacy',
157
+ transport: 'stdio',
158
+ command: 'node',
159
+ enabled: false,
160
+ },
161
+ });
162
+ await app.db.sequelize.getQueryInterface().removeColumn(collection.model.getTableName(), 'fromFile');
163
+ await app.upgrade();
164
+ expect(await app.aiManager.mcpManager.getMCP('legacy')).toMatchObject({ enabled: false, fromFile: false });
165
+ await app.db.getRepository('aiMcpClients').create({ values: { name: 'after-upgrade', transport: 'http' } });
166
+ expect(await app.aiManager.mcpManager.getMCP('after-upgrade')).toMatchObject({ fromFile: false });
167
+ });
168
+
66
169
  it('should expose cached mcp tools and allow updating permissions', async () => {
67
- const manager = mcpManager as any;
68
- manager.toolsMap = {
170
+ await app.db.getRepository('aiMcpClients').create({ values: { name: 'weather', transport: 'http' } });
171
+ Reflect.set(mcpManager, 'toolsMap', {
69
172
  weather: [
70
173
  {
71
174
  name: 'getForecast',
@@ -76,7 +179,7 @@ describe('MCP loader test cases', () => {
76
179
  description: 'Set default city',
77
180
  },
78
181
  ],
79
- };
182
+ });
80
183
 
81
184
  const tools = await mcpManager.listMCPTools();
82
185
  expect(tools.weather).toEqual([
package/src/loader/mcp.ts CHANGED
@@ -7,95 +7,84 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { importModule } from '@nocobase/utils';
11
- import { existsSync } from 'fs';
12
- import { Logger } from '@nocobase/logger';
13
- import { AIManager } from '../ai-manager';
14
- import { MCPOptions } from '../mcp-manager';
15
- import { LoadAndRegister } from './types';
16
- import { DirectoryScanner, DirectoryScannerOptions, FileDescriptor } from './scanner';
17
- import { isNonEmptyObject } from './utils';
10
+ import { storagePathJoin } from '@nocobase/utils';
11
+ import type { Logger } from '@nocobase/logger';
12
+ import { readFile } from 'node:fs/promises';
13
+ import type { MCPOptions, MCPRegistration } from '../mcp-manager/types';
14
+ import { normalizeMCPOptions } from '../mcp-manager/options-renderer';
18
15
 
19
- export type MCPLoaderOptions = { pluginName: string; scan: DirectoryScannerOptions; log?: Logger };
20
-
21
- export class MCPLoader extends LoadAndRegister<MCPLoaderOptions> {
22
- protected readonly scanner: DirectoryScanner;
23
-
24
- protected files: FileDescriptor[] = [];
25
- protected mcpDescriptors: MCPDescriptor[] = [];
26
- protected log: Logger;
16
+ export type MCPLoaderOptions = { log?: Pick<Logger, 'error'> };
27
17
 
18
+ export class MCPLoader {
28
19
  constructor(
29
- protected readonly ai: AIManager,
30
- protected readonly options: MCPLoaderOptions,
31
- ) {
32
- super(ai, options);
33
- this.log = options.log;
34
- this.scanner = new DirectoryScanner(this.options.scan);
35
- }
20
+ private readonly manager: MCPRegistration,
21
+ private readonly options: MCPLoaderOptions = {},
22
+ ) {}
36
23
 
37
- protected async scan(): Promise<void> {
38
- this.files = await this.scanner.scan();
39
- }
40
-
41
- protected async import(): Promise<void> {
42
- if (!this.files.length) {
24
+ async load(): Promise<void> {
25
+ const filePath = storagePathJoin('ai', 'mcp', 'servers.json');
26
+ let content: string;
27
+ try {
28
+ content = await readFile(filePath, 'utf8');
29
+ } catch (error) {
30
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
31
+ return;
32
+ }
33
+ this.options.log?.error(`MCP file ignored at ${filePath}: unable to read file`);
43
34
  return;
44
35
  }
45
36
 
46
- const descriptors = await Promise.all(
47
- this.files.map(async (file) => {
48
- const name = file.name;
49
- if (!existsSync(file.path)) {
50
- this.log?.error(`mcp [${name}] ignored: can not find definition file at ${file.path}`);
51
- return null;
52
- }
53
-
54
- try {
55
- const imported = await importModule(file.path);
56
- const mod = imported?.default ?? imported;
57
- const options = typeof mod === 'function' ? mod() : mod;
58
-
59
- if (!isNonEmptyObject(options)) {
60
- this.log?.warn(`mcp [${name}] register ignored: invalid definition at ${file.path}`);
61
- return null;
62
- }
63
-
64
- return {
65
- name,
66
- file,
67
- options: options as MCPOptions,
68
- } satisfies MCPDescriptor;
69
- } catch (e) {
70
- this.log?.error(`mcp [${name}] load fail: error occur when import ${file.path}`, e);
71
- return null;
72
- }
73
- }),
74
- );
75
-
76
- this.mcpDescriptors = descriptors.filter((item): item is MCPDescriptor => Boolean(item));
77
- }
78
-
79
- protected async register(): Promise<void> {
80
- if (!this.mcpDescriptors.length) {
37
+ let definitions: unknown;
38
+ try {
39
+ definitions = JSON.parse(content);
40
+ } catch {
41
+ // JSON parser messages may contain configuration fragments, including secrets.
42
+ this.options.log?.error(`MCP file ignored at ${filePath}: invalid JSON`);
43
+ return;
44
+ }
45
+ if (!Array.isArray(definitions)) {
46
+ this.options.log?.error(`MCP file ignored at ${filePath}: expected an array`);
81
47
  return;
82
48
  }
83
49
 
84
- const { mcpManager } = this.ai;
85
- for (const descriptor of this.mcpDescriptors) {
50
+ const registration: Record<string, MCPOptions> = Object.create(null);
51
+ for (const [index, value] of (definitions as unknown[]).entries()) {
52
+ const definition = value as Record<string, unknown>;
53
+ let reason: string | undefined;
54
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
55
+ reason = 'expected an object';
56
+ } else if (typeof definition.name !== 'string' || !definition.name.trim()) {
57
+ reason = 'name is required';
58
+ } else if (!(['stdio', 'http', 'sse'] as unknown[]).includes(definition.transport)) {
59
+ reason = 'unsupported or missing transport';
60
+ } else if (Object.hasOwn(value, 'enabled') || Object.hasOwn(value, 'fromFile')) {
61
+ reason = 'enabled and fromFile cannot be specified';
62
+ } else if (Object.hasOwn(registration, definition.name as string)) {
63
+ reason = 'duplicate name';
64
+ }
65
+ if (reason) {
66
+ this.options.log?.error(`MCP file ignored at ${filePath}, entry ${index + 1}: ${reason}`);
67
+ return;
68
+ }
69
+ // Keep the existing permissive connection-field normalization; only the file envelope is validated here.
86
70
  try {
87
- await mcpManager.registerMCP({
88
- [descriptor.name]: descriptor.options,
89
- });
90
- } catch (e) {
91
- this.log?.error(`mcp [${descriptor.name}] register ignored: error occur when invoke registerMCP`, e);
71
+ registration[definition.name as string] = normalizeMCPOptions({ ...definition, fromFile: true } as MCPOptions);
72
+ } catch {
73
+ this.options.log?.error(
74
+ `MCP file ignored at ${filePath}, entry ${index + 1}: unable to normalize configuration`,
75
+ );
76
+ return;
92
77
  }
93
78
  }
79
+
80
+ if (!Object.keys(registration).length) {
81
+ return;
82
+ }
83
+ try {
84
+ // Register only after the entire file passes validation.
85
+ await this.manager.registerMCP(registration);
86
+ } catch {
87
+ this.options.log?.error(`MCP registration failed at ${filePath}`);
88
+ }
94
89
  }
95
90
  }
96
-
97
- export type MCPDescriptor = {
98
- name: string;
99
- file: FileDescriptor;
100
- options: MCPOptions;
101
- };
@@ -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,23 +8,23 @@
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
- private readonly mcpRegistry = new Registry<MCPEntry>();
23
+ private readonly mcpRegistry = new Registry<MCPEntry>({ override: true });
23
24
  private readonly provideCollectionManager: () => { collectionManager: SequelizeCollectionManager };
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) {
@@ -51,7 +51,9 @@ export class DefaultMCPManager implements MCPManager {
51
51
  async registerMCP(registration: { [key: string | symbol]: MCPOptions }): Promise<void> {
52
52
  if (this.mode === 'memory') {
53
53
  for (const [name, options] of Object.entries(registration)) {
54
- this.mcpRegistry.register(name, this.normalizeEntry(name, options));
54
+ const entry = this.normalizeEntry(name, options);
55
+ entry.fromFile = entry.fromFile === true || this.mcpRegistry.get(name)?.fromFile === true;
56
+ this.mcpRegistry.register(name, entry);
55
57
  }
56
58
  } else {
57
59
  for (const [name, options] of Object.entries(registration)) {
@@ -100,8 +102,8 @@ export class DefaultMCPManager implements MCPManager {
100
102
  // Ignore close errors
101
103
  }
102
104
  this.client = null;
103
- this.toolsMap = {};
104
105
  }
106
+ this.toolsMap = {};
105
107
 
106
108
  // Get all enabled MCP entries
107
109
  const entries = await this.listMCP({ enabled: true, useUserContext: false });
@@ -123,12 +125,6 @@ export class DefaultMCPManager implements MCPManager {
123
125
  // Cache tools for each server
124
126
  for (const [serverName, tools] of Object.entries(toolsMap)) {
125
127
  this.toolsMap[serverName] = tools as StructuredToolInterface[];
126
- for (const tool of tools as StructuredToolInterface[]) {
127
- const toolName = `mcp-${serverName}-${tool.name}`;
128
- if (!(toolName in this.toolsPermissionMap)) {
129
- this.toolsPermissionMap[toolName] = tool.name.startsWith('get') ? 'ALLOW' : 'ASK';
130
- }
131
- }
132
128
  }
133
129
  }
134
130
 
@@ -138,95 +134,123 @@ export class DefaultMCPManager implements MCPManager {
138
134
 
139
135
  getMCPToolsProvider(): DynamicToolsProvider {
140
136
  return async (register: ToolsRegistration, filter): Promise<void> => {
141
- // Use cached tools from rebuildClient
142
- for (const [serverName, tools] of Object.entries(this.toolsMap)) {
143
- this.registerToolsFromMap(register, serverName, tools);
144
- }
145
-
146
- if (!filter?.ctx) {
147
- return;
148
- }
149
-
150
- const userToolsMap = await this.userContextClientManager.getToolsMap(filter.ctx);
151
- for (const [serverName, tools] of Object.entries(userToolsMap)) {
152
- this.registerToolsFromMap(register, serverName, tools);
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
+ }
153
167
  }
154
168
  };
155
169
  }
156
170
 
157
- private registerToolsFromMap(
158
- register: ToolsRegistration,
159
- serverName: string,
160
- tools: StructuredToolInterface[],
161
- ): void {
162
- for (const tool of tools) {
163
- const toolName = `mcp-${serverName}-${tool.name}`;
164
- this.ensureToolPermission(toolName, tool.name);
165
- const toolOptions: ToolsOptions = {
166
- scope: 'GENERAL',
167
- from: 'mcp',
168
- defaultPermission: this.toolsPermissionMap[toolName],
169
- introduction: {
170
- title: tool.name,
171
- about: tool.description,
172
- },
173
- definition: {
174
- name: toolName,
175
- description: tool.description || `MCP tool: ${tool.name} from ${serverName}`,
176
- schema: tool.schema,
177
- },
178
- invoke: async (_ctx: Context, args: any) => {
179
- try {
180
- const result = await tool.invoke(args);
181
- return result;
182
- } catch (error: any) {
183
- return {
184
- status: 'error' as const,
185
- content: error?.message || 'Tool invocation failed',
186
- };
187
- }
188
- },
189
- };
190
- register.registerTools(toolOptions);
191
- }
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
+ };
192
181
  }
193
182
 
194
- private ensureToolPermission(toolName: string, rawToolName: string) {
195
- if (!(toolName in this.toolsPermissionMap)) {
196
- 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();
197
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
+ );
198
196
  }
199
197
 
200
- async listMCPTools(ctx?: Context): Promise<Record<string, MCPToolEntry[]>> {
201
- const toolsMap = {
202
- ...this.toolsMap,
203
- ...(ctx ? await this.userContextClientManager.getToolsMap(ctx) : {}),
204
- };
205
-
206
- 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');
207
204
  }
208
205
 
209
- 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));
210
209
  return Object.fromEntries(
211
210
  Object.entries(toolsMap).map(([serverName, tools]) => [
212
211
  serverName,
213
- tools.map((tool) => {
214
- const toolName = `mcp-${serverName}-${tool.name}`;
215
- this.ensureToolPermission(toolName, tool.name);
216
- return {
217
- name: toolName,
218
- title: tool.name,
219
- description: tool.description,
220
- serverName,
221
- permission: this.toolsPermissionMap[toolName] ?? 'ASK',
222
- };
223
- }),
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
+ })),
224
219
  ]),
225
220
  );
226
221
  }
227
222
 
228
- async updateMCPToolPermission(toolName: string, permission: Permission): Promise<void> {
229
- 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
+ });
230
254
  }
231
255
 
232
256
  async clearUserContextCache(): Promise<void> {
@@ -372,6 +396,7 @@ export class DefaultMCPManager implements MCPManager {
372
396
  headers: normalizedEntry.headers,
373
397
  restart: normalizedEntry.restart,
374
398
  useUserContext: normalizedEntry.useUserContext,
399
+ fromFile: normalizedEntry.fromFile === true || existed.get('fromFile') === true,
375
400
  },
376
401
  { transaction },
377
402
  );
@@ -421,3 +446,4 @@ export function defineMCP(options: MCPOptions) {
421
446
  }
422
447
 
423
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
 
@@ -37,10 +37,13 @@ export type MCPOptions = {
37
37
  headers?: Record<string, string>;
38
38
  restart?: Record<string, any>;
39
39
  useUserContext?: boolean;
40
+ fromFile?: boolean;
40
41
  };
41
42
 
42
43
  export type MCPEntry = MCPOptions & {
43
44
  name: string;
45
+ title?: string;
46
+ description?: string;
44
47
  enabled: boolean;
45
48
  };
46
49