@nocobase/ai 2.2.43 → 2.2.45
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
|
@@ -0,0 +1,110 @@
|
|
|
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 { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { MCPLoader } from '../loader/mcp';
|
|
14
|
+
|
|
15
|
+
describe('deployment MCP file validation', () => {
|
|
16
|
+
let storage: string;
|
|
17
|
+
let filePath: string;
|
|
18
|
+
const registerMCP = vi.fn();
|
|
19
|
+
const log = { error: vi.fn() };
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
storage = await mkdtemp(join(tmpdir(), 'mcp-file-'));
|
|
23
|
+
// Include backslashes on POSIX too, so JSON-escaped path assertions fail outside Windows CI.
|
|
24
|
+
const configStorage = join(storage, 'deployment\\config');
|
|
25
|
+
vi.stubEnv('STORAGE_PATH', configStorage);
|
|
26
|
+
filePath = join(configStorage, 'ai', 'mcp', 'servers.json');
|
|
27
|
+
await mkdir(join(configStorage, 'ai', 'mcp'), { recursive: true });
|
|
28
|
+
registerMCP.mockReset();
|
|
29
|
+
log.error.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
vi.unstubAllEnvs();
|
|
34
|
+
await rm(storage, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const load = () => new MCPLoader({ registerMCP }, { log }).load();
|
|
38
|
+
|
|
39
|
+
it('uses STORAGE_PATH and treats a missing file or an empty array as a no-op', async () => {
|
|
40
|
+
await load();
|
|
41
|
+
await writeFile(filePath, '[]');
|
|
42
|
+
await load();
|
|
43
|
+
expect(registerMCP).not.toHaveBeenCalled();
|
|
44
|
+
expect(log.error).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('registers all transports with a trusted source marker', async () => {
|
|
48
|
+
await writeFile(
|
|
49
|
+
filePath,
|
|
50
|
+
JSON.stringify([
|
|
51
|
+
{ name: 'local', transport: 'stdio', command: 'node', args: [1, null, '--test'], useUserContext: true },
|
|
52
|
+
{ name: 'remote', transport: 'http', url: 'http://127.0.0.1/mcp', headers: { count: 2 }, useUserContext: true },
|
|
53
|
+
{ name: 'events', transport: 'sse', env: { count: 3 }, extraMetadata: 'kept' },
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
await load();
|
|
57
|
+
expect(registerMCP).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(registerMCP).toHaveBeenCalledWith({
|
|
59
|
+
local: expect.objectContaining({
|
|
60
|
+
transport: 'stdio',
|
|
61
|
+
fromFile: true,
|
|
62
|
+
args: ['1', '--test'],
|
|
63
|
+
useUserContext: false,
|
|
64
|
+
}),
|
|
65
|
+
remote: expect.objectContaining({
|
|
66
|
+
transport: 'http',
|
|
67
|
+
fromFile: true,
|
|
68
|
+
headers: { count: '2' },
|
|
69
|
+
useUserContext: true,
|
|
70
|
+
}),
|
|
71
|
+
events: expect.objectContaining({ transport: 'sse', fromFile: true, env: { count: '3' }, extraMetadata: 'kept' }),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
'{"secret":"do-not-log",',
|
|
77
|
+
'{}',
|
|
78
|
+
'null',
|
|
79
|
+
'[null]',
|
|
80
|
+
'[[]]',
|
|
81
|
+
'[1]',
|
|
82
|
+
'[{"transport":"http"}]',
|
|
83
|
+
'[{"name":" ","transport":"http"}]',
|
|
84
|
+
'[{"name":1,"transport":"http"}]',
|
|
85
|
+
'[{"name":"a"}]',
|
|
86
|
+
'[{"name":"a","transport":"websocket"}]',
|
|
87
|
+
'[{"name":"a","transport":"http","enabled":true}]',
|
|
88
|
+
'[{"name":"a","transport":"http","fromFile":false}]',
|
|
89
|
+
'[{"name":"a","transport":"http"},{"name":"a","transport":"sse"}]',
|
|
90
|
+
'[{"name":"good","transport":"http","env":{"TOKEN":"do-not-log"}},{"name":"bad"}]',
|
|
91
|
+
'[{"name":"good","transport":"http"},{"name":"bad","transport":"stdio","args":[{"toString":"do-not-log"}]}]',
|
|
92
|
+
'[{"name":"good","transport":"http"},{"name":"bad","transport":"stdio","env":{"TOKEN":{"toString":"do-not-log"}}}]',
|
|
93
|
+
'[{"name":"good","transport":"http"},{"name":"bad","transport":"http","headers":{"TOKEN":{"toString":"do-not-log"}}}]',
|
|
94
|
+
])('ignores the entire invalid file without exposing configuration: %s', async (contents) => {
|
|
95
|
+
await writeFile(filePath, contents);
|
|
96
|
+
await expect(load()).resolves.toBeUndefined();
|
|
97
|
+
expect(registerMCP).not.toHaveBeenCalled();
|
|
98
|
+
expect(log.error).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(JSON.stringify(log.error.mock.calls)).not.toContain('do-not-log');
|
|
100
|
+
expect(log.error).toHaveBeenCalledWith(expect.stringContaining(filePath));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not log arbitrary registration errors that may contain credentials', async () => {
|
|
104
|
+
await writeFile(filePath, '[{"name":"safe","transport":"http"}]');
|
|
105
|
+
registerMCP.mockRejectedValueOnce(new Error('do-not-log'));
|
|
106
|
+
await expect(load()).resolves.toBeUndefined();
|
|
107
|
+
expect(log.error).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(JSON.stringify(log.error.mock.calls)).not.toContain('do-not-log');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -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',
|