@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.
@@ -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 { AIManager } from '../ai-manager';
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
- pluginName: string;
16
- scan: DirectoryScannerOptions;
17
- log?: Logger;
12
+ log?: Pick<Logger, 'error'>;
18
13
  };
19
- export declare class MCPLoader extends LoadAndRegister<MCPLoaderOptions> {
20
- protected readonly ai: AIManager;
21
- protected readonly options: MCPLoaderOptions;
22
- protected readonly scanner: DirectoryScanner;
23
- protected files: FileDescriptor[];
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 import_fs = require("fs");
35
- var import_types = require("./types");
36
- var import_scanner = require("./scanner");
37
- var import_utils2 = require("./utils");
38
- const _MCPLoader = class _MCPLoader extends import_types.LoadAndRegister {
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
- scanner;
47
- files = [];
48
- mcpDescriptors = [];
49
- log;
50
- async scan() {
51
- this.files = await this.scanner.scan();
52
- }
53
- async import() {
54
- if (!this.files.length) {
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
- const descriptors = await Promise.all(
58
- this.files.map(async (file) => {
59
- var _a, _b, _c;
60
- const name = file.name;
61
- if (!(0, import_fs.existsSync)(file.path)) {
62
- (_a = this.log) == null ? void 0 : _a.error(`mcp [${name}] ignored: can not find definition file at ${file.path}`);
63
- return null;
64
- }
65
- try {
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 { mcpManager } = this.ai;
92
- for (const descriptor of this.mcpDescriptors) {
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
- await mcpManager.registerMCP({
95
- [descriptor.name]: descriptor.options
96
- });
97
- } catch (e) {
98
- (_a = this.log) == null ? void 0 : _a.error(`mcp [${descriptor.name}] register ignored: error occur when invoke registerMCP`, e);
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");
@@ -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
- this.mcpRegistry.register(name, this.normalizeEntry(name, options));
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.21",
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",
21
- "@nocobase/logger": "2.3.0-beta.21",
22
- "@nocobase/resourcer": "2.3.0-beta.21",
23
- "@nocobase/utils": "2.3.0-beta.21",
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": "a2bdd27cc7bc8ced9ad395b3a7468fc1850eef4a"
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 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
+ 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(aiManager, {
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('should load mcp definitions in mcp root directory', async () => {
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
- const manager = mcpManager as any;
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 { 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
- };
@@ -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
- 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)) {
@@ -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
  );
@@ -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