@nocobase/ai 2.3.0-beta.21 → 2.3.0-beta.23
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/index.js +7 -3
- package/lib/mcp-manager/types.d.ts +3 -0
- package/package.json +6 -6
- package/src/__tests__/mcp-file-loader.test.ts +110 -0
- package/src/__tests__/mcp.test.ts +113 -13
- package/src/loader/mcp.ts +66 -77
- package/src/mcp-manager/index.ts +5 -2
- package/src/mcp-manager/types.ts +3 -0
package/lib/loader/mcp.d.ts
CHANGED
|
@@ -6,30 +6,14 @@
|
|
|
6
6
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
|
-
import { Logger } from '@nocobase/logger';
|
|
10
|
-
import {
|
|
11
|
-
import { MCPOptions } from '../mcp-manager';
|
|
12
|
-
import { LoadAndRegister } from './types';
|
|
13
|
-
import { DirectoryScanner, DirectoryScannerOptions, FileDescriptor } from './scanner';
|
|
9
|
+
import type { Logger } from '@nocobase/logger';
|
|
10
|
+
import type { MCPRegistration } from '../mcp-manager/types';
|
|
14
11
|
export type MCPLoaderOptions = {
|
|
15
|
-
|
|
16
|
-
scan: DirectoryScannerOptions;
|
|
17
|
-
log?: Logger;
|
|
12
|
+
log?: Pick<Logger, 'error'>;
|
|
18
13
|
};
|
|
19
|
-
export declare class MCPLoader
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
protected mcpDescriptors: MCPDescriptor[];
|
|
25
|
-
protected log: Logger;
|
|
26
|
-
constructor(ai: AIManager, options: MCPLoaderOptions);
|
|
27
|
-
protected scan(): Promise<void>;
|
|
28
|
-
protected import(): Promise<void>;
|
|
29
|
-
protected register(): Promise<void>;
|
|
14
|
+
export declare class MCPLoader {
|
|
15
|
+
private readonly manager;
|
|
16
|
+
private readonly options;
|
|
17
|
+
constructor(manager: MCPRegistration, options?: MCPLoaderOptions);
|
|
18
|
+
load(): Promise<void>;
|
|
30
19
|
}
|
|
31
|
-
export type MCPDescriptor = {
|
|
32
|
-
name: string;
|
|
33
|
-
file: FileDescriptor;
|
|
34
|
-
options: MCPOptions;
|
|
35
|
-
};
|
package/lib/loader/mcp.js
CHANGED
|
@@ -31,73 +31,73 @@ __export(mcp_exports, {
|
|
|
31
31
|
});
|
|
32
32
|
module.exports = __toCommonJS(mcp_exports);
|
|
33
33
|
var import_utils = require("@nocobase/utils");
|
|
34
|
-
var
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
constructor(ai, options) {
|
|
40
|
-
super(ai, options);
|
|
41
|
-
this.ai = ai;
|
|
34
|
+
var import_promises = require("node:fs/promises");
|
|
35
|
+
var import_options_renderer = require("../mcp-manager/options-renderer");
|
|
36
|
+
const _MCPLoader = class _MCPLoader {
|
|
37
|
+
constructor(manager, options = {}) {
|
|
38
|
+
this.manager = manager;
|
|
42
39
|
this.options = options;
|
|
43
|
-
this.log = options.log;
|
|
44
|
-
this.scanner = new import_scanner.DirectoryScanner(this.options.scan);
|
|
45
40
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
async load() {
|
|
42
|
+
var _a, _b, _c, _d, _e, _f;
|
|
43
|
+
const filePath = (0, import_utils.storagePathJoin)("ai", "mcp", "servers.json");
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = await (0, import_promises.readFile)(filePath, "utf8");
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
(_a = this.options.log) == null ? void 0 : _a.error(`MCP file ignored at ${filePath}: unable to read file`);
|
|
55
52
|
return;
|
|
56
53
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const imported = await (0, import_utils.importModule)(file.path);
|
|
67
|
-
const mod = (imported == null ? void 0 : imported.default) ?? imported;
|
|
68
|
-
const options = typeof mod === "function" ? mod() : mod;
|
|
69
|
-
if (!(0, import_utils2.isNonEmptyObject)(options)) {
|
|
70
|
-
(_b = this.log) == null ? void 0 : _b.warn(`mcp [${name}] register ignored: invalid definition at ${file.path}`);
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
return {
|
|
74
|
-
name,
|
|
75
|
-
file,
|
|
76
|
-
options
|
|
77
|
-
};
|
|
78
|
-
} catch (e) {
|
|
79
|
-
(_c = this.log) == null ? void 0 : _c.error(`mcp [${name}] load fail: error occur when import ${file.path}`, e);
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
);
|
|
84
|
-
this.mcpDescriptors = descriptors.filter((item) => Boolean(item));
|
|
85
|
-
}
|
|
86
|
-
async register() {
|
|
87
|
-
var _a;
|
|
88
|
-
if (!this.mcpDescriptors.length) {
|
|
54
|
+
let definitions;
|
|
55
|
+
try {
|
|
56
|
+
definitions = JSON.parse(content);
|
|
57
|
+
} catch {
|
|
58
|
+
(_b = this.options.log) == null ? void 0 : _b.error(`MCP file ignored at ${filePath}: invalid JSON`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(definitions)) {
|
|
62
|
+
(_c = this.options.log) == null ? void 0 : _c.error(`MCP file ignored at ${filePath}: expected an array`);
|
|
89
63
|
return;
|
|
90
64
|
}
|
|
91
|
-
const
|
|
92
|
-
for (const
|
|
65
|
+
const registration = /* @__PURE__ */ Object.create(null);
|
|
66
|
+
for (const [index, value] of definitions.entries()) {
|
|
67
|
+
const definition = value;
|
|
68
|
+
let reason;
|
|
69
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
70
|
+
reason = "expected an object";
|
|
71
|
+
} else if (typeof definition.name !== "string" || !definition.name.trim()) {
|
|
72
|
+
reason = "name is required";
|
|
73
|
+
} else if (!["stdio", "http", "sse"].includes(definition.transport)) {
|
|
74
|
+
reason = "unsupported or missing transport";
|
|
75
|
+
} else if (Object.hasOwn(value, "enabled") || Object.hasOwn(value, "fromFile")) {
|
|
76
|
+
reason = "enabled and fromFile cannot be specified";
|
|
77
|
+
} else if (Object.hasOwn(registration, definition.name)) {
|
|
78
|
+
reason = "duplicate name";
|
|
79
|
+
}
|
|
80
|
+
if (reason) {
|
|
81
|
+
(_d = this.options.log) == null ? void 0 : _d.error(`MCP file ignored at ${filePath}, entry ${index + 1}: ${reason}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
93
84
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
registration[definition.name] = (0, import_options_renderer.normalizeMCPOptions)({ ...definition, fromFile: true });
|
|
86
|
+
} catch {
|
|
87
|
+
(_e = this.options.log) == null ? void 0 : _e.error(
|
|
88
|
+
`MCP file ignored at ${filePath}, entry ${index + 1}: unable to normalize configuration`
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
99
91
|
}
|
|
100
92
|
}
|
|
93
|
+
if (!Object.keys(registration).length) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await this.manager.registerMCP(registration);
|
|
98
|
+
} catch {
|
|
99
|
+
(_f = this.options.log) == null ? void 0 : _f.error(`MCP registration failed at ${filePath}`);
|
|
100
|
+
}
|
|
101
101
|
}
|
|
102
102
|
};
|
|
103
103
|
__name(_MCPLoader, "MCPLoader");
|
package/lib/mcp-manager/index.js
CHANGED
|
@@ -48,7 +48,7 @@ const _DefaultMCPManager = class _DefaultMCPManager {
|
|
|
48
48
|
buildConnection: /* @__PURE__ */ __name((options) => this.buildMCPConnection(options), "buildConnection")
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
|
-
mcpRegistry = new import_utils.Registry();
|
|
51
|
+
mcpRegistry = new import_utils.Registry({ override: true });
|
|
52
52
|
provideCollectionManager;
|
|
53
53
|
mode = "memory";
|
|
54
54
|
client = null;
|
|
@@ -67,9 +67,12 @@ const _DefaultMCPManager = class _DefaultMCPManager {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
async registerMCP(registration) {
|
|
70
|
+
var _a;
|
|
70
71
|
if (this.mode === "memory") {
|
|
71
72
|
for (const [name, options] of Object.entries(registration)) {
|
|
72
|
-
|
|
73
|
+
const entry = this.normalizeEntry(name, options);
|
|
74
|
+
entry.fromFile = entry.fromFile === true || ((_a = this.mcpRegistry.get(name)) == null ? void 0 : _a.fromFile) === true;
|
|
75
|
+
this.mcpRegistry.register(name, entry);
|
|
73
76
|
}
|
|
74
77
|
} else {
|
|
75
78
|
for (const [name, options] of Object.entries(registration)) {
|
|
@@ -333,7 +336,8 @@ ${(error == null ? void 0 : error.stack) || ""}` : (error == null ? void 0 : err
|
|
|
333
336
|
url: normalizedEntry.url,
|
|
334
337
|
headers: normalizedEntry.headers,
|
|
335
338
|
restart: normalizedEntry.restart,
|
|
336
|
-
useUserContext: normalizedEntry.useUserContext
|
|
339
|
+
useUserContext: normalizedEntry.useUserContext,
|
|
340
|
+
fromFile: normalizedEntry.fromFile === true || existed.get("fromFile") === true
|
|
337
341
|
},
|
|
338
342
|
{ transaction }
|
|
339
343
|
);
|
|
@@ -35,9 +35,12 @@ export type MCPOptions = {
|
|
|
35
35
|
headers?: Record<string, string>;
|
|
36
36
|
restart?: Record<string, any>;
|
|
37
37
|
useUserContext?: boolean;
|
|
38
|
+
fromFile?: boolean;
|
|
38
39
|
};
|
|
39
40
|
export type MCPEntry = MCPOptions & {
|
|
40
41
|
name: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
description?: string;
|
|
41
44
|
enabled: boolean;
|
|
42
45
|
};
|
|
43
46
|
export type MCPFilter = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/ai",
|
|
3
|
-
"version": "2.3.0-beta.
|
|
3
|
+
"version": "2.3.0-beta.23",
|
|
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.
|
|
21
|
-
"@nocobase/logger": "2.3.0-beta.
|
|
22
|
-
"@nocobase/resourcer": "2.3.0-beta.
|
|
23
|
-
"@nocobase/utils": "2.3.0-beta.
|
|
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",
|
|
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": "
|
|
40
|
+
"gitHead": "53ad17b1117bae431d21cddaac7af7da484967fc"
|
|
41
41
|
}
|
|
@@ -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
|
+
});
|
|
@@ -8,44 +8,72 @@
|
|
|
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
|
+
filePath = join(storage, 'ai', 'mcp', 'servers.json');
|
|
38
|
+
await mkdir(join(storage, 'ai', 'mcp'), { recursive: true });
|
|
24
39
|
app = await createMockServer({
|
|
25
40
|
plugins: ['nocobase'],
|
|
41
|
+
database: { dialect: 'sqlite', storage: join(storage, 'test.sqlite') },
|
|
26
42
|
});
|
|
27
43
|
await app.pm.enable('ai');
|
|
28
44
|
aiManager = app.aiManager;
|
|
29
45
|
mcpManager = aiManager.mcpManager;
|
|
30
|
-
loader = new MCPLoader(
|
|
31
|
-
scan: {
|
|
32
|
-
basePath,
|
|
33
|
-
pattern: ['**/mcp/*.ts', '!**/mcp/*.d.ts'],
|
|
34
|
-
},
|
|
35
|
-
});
|
|
46
|
+
loader = new MCPLoader(mcpManager);
|
|
36
47
|
});
|
|
37
48
|
|
|
38
49
|
afterEach(async () => {
|
|
39
50
|
await app.destroy();
|
|
51
|
+
vi.unstubAllEnvs();
|
|
52
|
+
await rm(storage, { recursive: true, force: true });
|
|
40
53
|
});
|
|
41
54
|
|
|
42
|
-
it('
|
|
55
|
+
it('loads JSON deployment definitions into the database', async () => {
|
|
56
|
+
await writeFile(
|
|
57
|
+
filePath,
|
|
58
|
+
JSON.stringify([
|
|
59
|
+
{
|
|
60
|
+
name: 'weather',
|
|
61
|
+
transport: 'http',
|
|
62
|
+
url: 'http://localhost:8123/mcp',
|
|
63
|
+
headers: { Authorization: 'Bearer test-token' },
|
|
64
|
+
env: { MCP_ENV: 'test' },
|
|
65
|
+
args: ['--foo'],
|
|
66
|
+
restart: { enabled: true },
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
);
|
|
43
70
|
await loader.load();
|
|
44
71
|
|
|
45
72
|
const entry = await mcpManager.getMCP('weather');
|
|
46
73
|
expect(entry).toBeDefined();
|
|
47
74
|
expect(entry.name).toBe('weather');
|
|
48
75
|
expect(entry.enabled).toBe(true);
|
|
76
|
+
expect(entry.fromFile).toBe(true);
|
|
49
77
|
expect(entry.transport).toBe('http');
|
|
50
78
|
expect(entry.url).toBe('http://localhost:8123/mcp');
|
|
51
79
|
expect(entry.headers).toEqual({
|
|
@@ -63,9 +91,81 @@ describe('MCP loader test cases', () => {
|
|
|
63
91
|
expect(enabledEntries.map((item) => item.name)).toEqual(['weather']);
|
|
64
92
|
});
|
|
65
93
|
|
|
94
|
+
it('preserves disabled state and management through updates, repeated loads and removed entries', async () => {
|
|
95
|
+
const definitions = [
|
|
96
|
+
{ name: 'local', transport: 'stdio', command: 'node', useUserContext: true },
|
|
97
|
+
{ name: 'remote', transport: 'http', url: 'http://127.0.0.1/old', useUserContext: true },
|
|
98
|
+
];
|
|
99
|
+
await writeFile(filePath, JSON.stringify(definitions));
|
|
100
|
+
await loader.load();
|
|
101
|
+
expect(await mcpManager.getMCP('local')).toMatchObject({ enabled: true, fromFile: true, useUserContext: false });
|
|
102
|
+
const repo = app.db.getRepository('aiMcpClients');
|
|
103
|
+
await repo.update({ filterByTk: 'remote', values: { enabled: false } });
|
|
104
|
+
definitions[1].url = 'http://127.0.0.1/new';
|
|
105
|
+
await writeFile(filePath, JSON.stringify(definitions));
|
|
106
|
+
await loader.load();
|
|
107
|
+
await loader.load();
|
|
108
|
+
expect(await repo.count()).toBe(2);
|
|
109
|
+
expect(await mcpManager.getMCP('remote')).toMatchObject({
|
|
110
|
+
enabled: false,
|
|
111
|
+
fromFile: true,
|
|
112
|
+
url: definitions[1].url,
|
|
113
|
+
useUserContext: true,
|
|
114
|
+
});
|
|
115
|
+
await writeFile(filePath, '[]');
|
|
116
|
+
await loader.load();
|
|
117
|
+
await rm(filePath);
|
|
118
|
+
await loader.load();
|
|
119
|
+
await mcpManager.registerMCP({ remote: { transport: 'http', url: 'http://127.0.0.1/trusted', fromFile: false } });
|
|
120
|
+
expect(await mcpManager.getMCP('remote')).toMatchObject({ enabled: false, fromFile: true });
|
|
121
|
+
expect(await repo.count()).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
it('validates before memory registration and persists before initializing connections', async () => {
|
|
124
|
+
const manager = new DefaultMCPManager(app);
|
|
125
|
+
const memoryLoader = new MCPLoader(manager);
|
|
126
|
+
await manager.registerMCP({ local: { transport: 'stdio', command: 'old' } });
|
|
127
|
+
await writeFile(filePath, JSON.stringify([{ name: 'local', transport: 'stdio', command: 'node' }]));
|
|
128
|
+
await memoryLoader.load();
|
|
129
|
+
await memoryLoader.load();
|
|
130
|
+
await manager.registerMCP({ local: { transport: 'stdio', command: 'trusted', fromFile: false } });
|
|
131
|
+
expect(await app.db.getRepository('aiMcpClients').count()).toBe(0);
|
|
132
|
+
const rebuild = vi.spyOn(manager, 'rebuildClient').mockImplementation(async () => {
|
|
133
|
+
expect(await manager.getMCP('local')).toMatchObject({ command: 'trusted', fromFile: true, enabled: true });
|
|
134
|
+
});
|
|
135
|
+
await manager.init();
|
|
136
|
+
expect(rebuild).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(await app.db.getRepository('aiMcpClients').count()).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('loads deployment configuration through the actual afterStart lifecycle', async () => {
|
|
141
|
+
await writeFile(
|
|
142
|
+
filePath,
|
|
143
|
+
JSON.stringify([{ name: 'startup', transport: 'http', url: 'http://127.0.0.1/startup' }]),
|
|
144
|
+
);
|
|
145
|
+
await app.stop();
|
|
146
|
+
await app.start();
|
|
147
|
+
expect(await app.aiManager.mcpManager.getMCP('startup')).toMatchObject({ enabled: true, fromFile: true });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('adds the source column on upgrade without changing existing enabled state', async () => {
|
|
151
|
+
const collection = app.db.getCollection('aiMcpClients');
|
|
152
|
+
await app.db.getRepository('aiMcpClients').create({
|
|
153
|
+
values: {
|
|
154
|
+
name: 'legacy',
|
|
155
|
+
transport: 'stdio',
|
|
156
|
+
command: 'node',
|
|
157
|
+
enabled: false,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await app.db.sequelize.getQueryInterface().removeColumn(collection.model.getTableName(), 'fromFile');
|
|
161
|
+
await app.upgrade();
|
|
162
|
+
expect(await app.aiManager.mcpManager.getMCP('legacy')).toMatchObject({ enabled: false, fromFile: false });
|
|
163
|
+
await app.db.getRepository('aiMcpClients').create({ values: { name: 'after-upgrade', transport: 'http' } });
|
|
164
|
+
expect(await app.aiManager.mcpManager.getMCP('after-upgrade')).toMatchObject({ fromFile: false });
|
|
165
|
+
});
|
|
166
|
+
|
|
66
167
|
it('should expose cached mcp tools and allow updating permissions', async () => {
|
|
67
|
-
|
|
68
|
-
manager.toolsMap = {
|
|
168
|
+
Reflect.set(mcpManager, 'toolsMap', {
|
|
69
169
|
weather: [
|
|
70
170
|
{
|
|
71
171
|
name: 'getForecast',
|
|
@@ -76,7 +176,7 @@ describe('MCP loader test cases', () => {
|
|
|
76
176
|
description: 'Set default city',
|
|
77
177
|
},
|
|
78
178
|
],
|
|
79
|
-
};
|
|
179
|
+
});
|
|
80
180
|
|
|
81
181
|
const tools = await mcpManager.listMCPTools();
|
|
82
182
|
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
|
-
};
|
package/src/mcp-manager/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { normalizeMCPOptions, renderMCPOptions } from './options-renderer';
|
|
|
19
19
|
import { UserContextMCPClientManager } from './user-context-client-manager';
|
|
20
20
|
|
|
21
21
|
export class DefaultMCPManager implements MCPManager {
|
|
22
|
-
private readonly mcpRegistry = new Registry<MCPEntry>();
|
|
22
|
+
private readonly mcpRegistry = new Registry<MCPEntry>({ override: true });
|
|
23
23
|
private readonly provideCollectionManager: () => { collectionManager: SequelizeCollectionManager };
|
|
24
24
|
private mode = 'memory';
|
|
25
25
|
private client: MultiServerMCPClient | null = null;
|
|
@@ -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)) {
|
|
@@ -372,6 +374,7 @@ export class DefaultMCPManager implements MCPManager {
|
|
|
372
374
|
headers: normalizedEntry.headers,
|
|
373
375
|
restart: normalizedEntry.restart,
|
|
374
376
|
useUserContext: normalizedEntry.useUserContext,
|
|
377
|
+
fromFile: normalizedEntry.fromFile === true || existed.get('fromFile') === true,
|
|
375
378
|
},
|
|
376
379
|
{ transaction },
|
|
377
380
|
);
|
package/src/mcp-manager/types.ts
CHANGED
|
@@ -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
|
|