@nocobase/ai 2.3.0-beta.22 → 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.
- package/lib/loader/mcp.d.ts +8 -24
- package/lib/loader/mcp.js +58 -58
- package/lib/mcp-manager/errors.d.ts +12 -0
- package/lib/mcp-manager/errors.js +45 -0
- package/lib/mcp-manager/index.d.ts +5 -5
- package/lib/mcp-manager/index.js +103 -75
- package/lib/mcp-manager/types.d.ts +4 -1
- package/package.json +6 -6
- package/src/__tests__/mcp-file-loader.test.ts +110 -0
- package/src/__tests__/mcp-permissions.test.ts +439 -0
- package/src/__tests__/mcp-user-context.test.ts +5 -0
- package/src/__tests__/mcp.test.ts +116 -13
- package/src/loader/mcp.ts +66 -77
- package/src/mcp-manager/errors.ts +15 -0
- package/src/mcp-manager/index.ts +109 -83
- package/src/mcp-manager/types.ts +4 -1
|
@@ -8,44 +8,74 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createMockServer, MockServer } from '@nocobase/test';
|
|
11
|
-
import
|
|
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
|
-
|
|
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(
|
|
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('
|
|
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
|
-
|
|
68
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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 = {
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
85
|
-
for (const
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
package/src/mcp-manager/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
195
|
-
if (!
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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';
|
package/src/mcp-manager/types.ts
CHANGED
|
@@ -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
|
|