fix(plugin-ai): secure file-managed MCP servers

This commit is contained in:
Drol
2026-08-28 12:01:42 +08:00
parent 032a4f6913
commit e529f263d3
15 changed files with 695 additions and 170 deletions
+134 -27
View File
@@ -8,17 +8,21 @@
*/
import { createMockServer, MockServer } from '@nocobase/test';
import path from 'path';
import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AIManager } from '../ai-manager';
import { MCPLoader } from '../loader';
import { MCPManager } from '../mcp-manager';
describe('MCP loader test cases', () => {
const basePath = path.resolve(__dirname, 'resource', 'ai');
const fixturePath = path.resolve(__dirname, 'resource', 'ai', 'mcp', 'servers.json');
let app: MockServer;
let aiManager: AIManager;
let mcpManager: MCPManager;
let loader: MCPLoader;
let tempDirectory: string;
let serversPath: string;
beforeEach(async () => {
app = await createMockServer({
@@ -27,44 +31,147 @@ describe('MCP loader test cases', () => {
await app.pm.enable('ai');
aiManager = app.aiManager;
mcpManager = aiManager.mcpManager;
loader = new MCPLoader(aiManager, {
scan: {
basePath,
pattern: ['**/mcp/*.ts', '!**/mcp/*.d.ts'],
},
});
tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'nocobase-mcp-loader-'));
serversPath = path.join(tempDirectory, 'servers.json');
});
afterEach(async () => {
await app.destroy();
await rm(tempDirectory, { recursive: true, force: true });
});
it('should load mcp definitions in mcp root directory', async () => {
const load = async (filePath = serversPath) => {
const loader = new MCPLoader(aiManager, {
serversPath: filePath,
log: app.log,
});
await loader.load();
};
it('loads JSON definitions, overwrites connection settings, and preserves enabled state', async () => {
await app.db.getRepository('aiMcpClients').create({
values: {
name: 'weather',
enabled: false,
transport: 'sse',
url: 'http://old.example.com/mcp',
fromFile: false,
},
});
await cp(fixturePath, serversPath);
await load();
const entry = await mcpManager.getMCP('weather');
expect(entry).toBeDefined();
expect(entry.name).toBe('weather');
expect(entry.enabled).toBe(true);
expect(entry.transport).toBe('http');
expect(entry.url).toBe('http://localhost:8123/mcp');
expect(entry.headers).toEqual({
Authorization: 'Bearer test-token',
});
expect(entry.env).toEqual({
MCP_ENV: 'test',
});
expect(entry.args).toEqual(['--foo']);
expect(entry.restart).toEqual({
enabled: true,
expect(entry).toMatchObject({
name: 'weather',
enabled: false,
fromFile: true,
transport: 'http',
url: 'http://localhost:8123/mcp',
headers: { Authorization: 'Bearer test-token' },
env: { MCP_ENV: 'test' },
args: ['--foo'],
restart: { enabled: true },
});
const enabledEntries = await mcpManager.listMCP({ enabled: true, transport: 'http', name: 'weath' });
expect(enabledEntries.map((item) => item.name)).toEqual(['weather']);
await load();
const weatherRecords = await app.db.getRepository('aiMcpClients').find({ filter: { name: 'weather' } });
expect(weatherRecords).toHaveLength(1);
expect(weatherRecords[0].get('enabled')).toBe(false);
expect(weatherRecords[0].get('fromFile')).toBe(true);
});
it('defaults newly created file-managed records to enabled', async () => {
await cp(fixturePath, serversPath);
await load();
expect(await mcpManager.getMCP('weather')).toMatchObject({ enabled: true, fromFile: true });
});
it('ignores a missing file', async () => {
await load(path.join(tempDirectory, 'missing.json'));
expect(await mcpManager.listMCP({})).toEqual([]);
});
it.each([
['malformed JSON', '{'],
['non-array root', '{"name":"weather"}'],
['non-object entry', '[null]'],
['missing name', '[{"transport":"http"}]'],
['empty name', '[{"name":" ","transport":"http"}]'],
['missing transport', '[{"name":"weather"}]'],
['unsupported transport', '[{"name":"weather","transport":"websocket"}]'],
['duplicate names', '[{"name":"weather","transport":"http"},{"name":"weather","transport":"sse"}]'],
['reserved enabled field', '[{"name":"weather","transport":"http","enabled":false}]'],
['reserved fromFile field', '[{"name":"weather","transport":"http","fromFile":false}]'],
])('ignores the complete file when it has %s', async (_caseName, content) => {
await writeFile(serversPath, content);
await load();
expect(await mcpManager.listMCP({})).toEqual([]);
});
it('does not partially register entries when one entry is invalid', async () => {
await writeFile(
serversPath,
JSON.stringify([{ name: 'valid', transport: 'http', url: 'https://example.com/mcp' }, { name: 'invalid' }]),
);
await load();
expect(await mcpManager.getMCP('valid')).toBeUndefined();
});
it('passes supported connection settings through normalization and persistence', async () => {
await writeFile(
serversPath,
JSON.stringify([
{
name: 'stdio-service',
transport: 'stdio',
command: 'npx',
args: ['-y', 123],
env: { TOKEN: 123 },
restart: { enabled: true },
useUserContext: true,
},
{
name: 'remote-service',
transport: 'sse',
url: 'https://example.com/mcp',
headers: { Authorization: 123 },
useUserContext: true,
},
]),
);
await load();
expect(await mcpManager.getMCP('stdio-service')).toMatchObject({
command: 'npx',
args: ['-y', '123'],
env: { TOKEN: '123' },
restart: { enabled: true },
useUserContext: false,
fromFile: true,
});
expect(await mcpManager.getMCP('remote-service')).toMatchObject({
transport: 'sse',
url: 'https://example.com/mcp',
headers: { Authorization: '123' },
useUserContext: true,
fromFile: true,
});
});
it('should expose cached mcp tools and allow updating permissions', async () => {
const manager = mcpManager as any;
const manager = mcpManager as unknown as {
toolsMap: Record<string, Array<{ name: string; description: string }>>;
};
manager.toolsMap = {
weather: [
{
@@ -0,0 +1,17 @@
[
{
"name": "weather",
"transport": "http",
"url": "http://localhost:8123/mcp",
"headers": {
"Authorization": "Bearer test-token"
},
"env": {
"MCP_ENV": "test"
},
"args": ["--foo"],
"restart": {
"enabled": true
}
}
]
@@ -1,25 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { defineMCP } from '../../../../index';
export default defineMCP({
transport: 'http',
url: 'http://localhost:8123/mcp',
headers: {
Authorization: 'Bearer test-token',
},
env: {
MCP_ENV: 'test',
},
args: ['--foo'],
restart: {
enabled: true,
},
});
+94 -71
View File
@@ -7,23 +7,29 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { importModule } from '@nocobase/utils';
import { existsSync } from 'fs';
import { Logger } from '@nocobase/logger';
import { readFile } from 'node:fs/promises';
import { AIManager } from '../ai-manager';
import { MCPOptions } from '../mcp-manager';
import { MCPOptions, MCPTransport } from '../mcp-manager';
import { LoadAndRegister } from './types';
import { DirectoryScanner, DirectoryScannerOptions, FileDescriptor } from './scanner';
import { isNonEmptyObject } from './utils';
export type MCPLoaderOptions = { pluginName: string; scan: DirectoryScannerOptions; log?: Logger };
export type MCPLoaderOptions = { serversPath: string; log?: Logger };
type MCPFileEntry = MCPOptions & { name: string };
const supportedTransports = new Set<MCPTransport>(['stdio', 'http', 'sse']);
const connectionSettingKeys = ['command', 'args', 'env', 'url', 'headers', 'restart', 'useUserContext'] as const;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype;
export class MCPLoader extends LoadAndRegister<MCPLoaderOptions> {
protected readonly scanner: DirectoryScanner;
protected files: FileDescriptor[] = [];
protected mcpDescriptors: MCPDescriptor[] = [];
protected log: Logger;
protected content: string | null = null;
protected entries: MCPFileEntry[] = [];
protected log?: Logger;
constructor(
protected readonly ai: AIManager,
@@ -31,71 +37,88 @@ export class MCPLoader extends LoadAndRegister<MCPLoaderOptions> {
) {
super(ai, options);
this.log = options.log;
this.scanner = new DirectoryScanner(this.options.scan);
}
protected async scan(): Promise<void> {
this.files = await this.scanner.scan();
}
this.content = null;
this.entries = [];
protected async import(): Promise<void> {
if (!this.files.length) {
return;
}
const descriptors = await Promise.all(
this.files.map(async (file) => {
const name = file.name;
if (!existsSync(file.path)) {
this.log?.error(`mcp [${name}] ignored: can not find definition file at ${file.path}`);
return null;
}
try {
const imported = await importModule(file.path);
const mod = imported?.default ?? imported;
const options = typeof mod === 'function' ? mod() : mod;
if (!isNonEmptyObject(options)) {
this.log?.warn(`mcp [${name}] register ignored: invalid definition at ${file.path}`);
return null;
}
return {
name,
file,
options: options as MCPOptions,
} satisfies MCPDescriptor;
} catch (e) {
this.log?.error(`mcp [${name}] load fail: error occur when import ${file.path}`, e);
return null;
}
}),
);
this.mcpDescriptors = descriptors.filter((item): item is MCPDescriptor => Boolean(item));
}
protected async register(): Promise<void> {
if (!this.mcpDescriptors.length) {
return;
}
const { mcpManager } = this.ai;
for (const descriptor of this.mcpDescriptors) {
try {
await mcpManager.registerMCP({
[descriptor.name]: descriptor.options,
});
} catch (e) {
this.log?.error(`mcp [${descriptor.name}] register ignored: error occur when invoke registerMCP`, e);
try {
this.content = await readFile(this.options.serversPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
this.log?.error(`MCP server configuration ignored: failed to read ${this.options.serversPath}`, error);
}
}
}
}
export type MCPDescriptor = {
name: string;
file: FileDescriptor;
options: MCPOptions;
};
protected async import(): Promise<void> {
if (this.content === null) {
return;
}
try {
const parsed: unknown = JSON.parse(this.content);
this.entries = this.validateEntries(parsed);
} catch (error) {
this.entries = [];
this.log?.error(`MCP server configuration ignored: invalid file ${this.options.serversPath}`, error);
}
}
protected async register(): Promise<void> {
for (const entry of this.entries) {
const { name, ...options } = entry;
try {
await this.ai.mcpManager.registerMCP({
[name]: {
...options,
fromFile: true,
},
});
} catch (error) {
this.log?.error(`MCP server [${name}] registration ignored`, error);
}
}
}
private validateEntries(value: unknown): MCPFileEntry[] {
if (!Array.isArray(value)) {
throw new Error('Root value must be an array');
}
const names = new Set<string>();
return value.map((item, index) => {
if (!isPlainObject(item)) {
throw new Error(`Entry at index ${index} must be a plain object`);
}
if (typeof item.name !== 'string' || !item.name.trim()) {
throw new Error(`Entry at index ${index} must have a non-empty string name`);
}
if (typeof item.transport !== 'string' || !supportedTransports.has(item.transport as MCPTransport)) {
throw new Error(`Entry [${item.name}] must use a supported transport`);
}
if (names.has(item.name)) {
throw new Error(`Duplicate MCP server name: ${item.name}`);
}
if (
Object.prototype.hasOwnProperty.call(item, 'enabled') ||
Object.prototype.hasOwnProperty.call(item, 'fromFile')
) {
throw new Error(`Entry [${item.name}] must not define enabled or fromFile`);
}
names.add(item.name);
const entry: MCPFileEntry = {
name: item.name,
transport: item.transport as MCPTransport,
};
for (const key of connectionSettingKeys) {
if (Object.prototype.hasOwnProperty.call(item, key)) {
Object.assign(entry, { [key]: item[key] });
}
}
return entry;
});
}
}
+13 -3
View File
@@ -12,7 +12,15 @@ import { Op } from '@nocobase/database';
import { Registry } from '@nocobase/utils';
import { MultiServerMCPClient, StdioConnection, StreamableHTTPConnection } from '@langchain/mcp-adapters';
import { StructuredToolInterface } from '@langchain/core/tools';
import { MCPEntry, MCPFilter, MCPManager, MCPOptions, MCPTestResult, MCPToolEntry } from './types';
import {
MCPEntry,
MCPFilter,
MCPManager,
MCPOptions,
MCPRegistrationOptions,
MCPTestResult,
MCPToolEntry,
} from './types';
import type { DynamicToolsProvider, Permission, ToolsRegistration, ToolsOptions } from '../tools-manager/types';
import type { Context } from '@nocobase/actions';
import { normalizeMCPOptions, renderMCPOptions } from './options-renderer';
@@ -48,7 +56,7 @@ export class DefaultMCPManager implements MCPManager {
}
}
async registerMCP(registration: { [key: string | symbol]: MCPOptions }): Promise<void> {
async registerMCP(registration: { [key: string | symbol]: MCPRegistrationOptions }): Promise<void> {
if (this.mode === 'memory') {
for (const [name, options] of Object.entries(registration)) {
this.mcpRegistry.register(name, this.normalizeEntry(name, options));
@@ -372,6 +380,7 @@ export class DefaultMCPManager implements MCPManager {
headers: normalizedEntry.headers,
restart: normalizedEntry.restart,
useUserContext: normalizedEntry.useUserContext,
...(normalizedEntry.fromFile ? { fromFile: true } : {}),
},
{ transaction },
);
@@ -387,7 +396,7 @@ export class DefaultMCPManager implements MCPManager {
});
}
private normalizeEntry(name: string, options: MCPOptions): MCPEntry {
private normalizeEntry(name: string, options: MCPRegistrationOptions): MCPEntry {
const entry: MCPEntry = {
name,
enabled: true,
@@ -395,6 +404,7 @@ export class DefaultMCPManager implements MCPManager {
args: options.args ?? [],
env: options.env ?? {},
useUserContext: options.useUserContext === true,
fromFile: options.fromFile === true,
};
return normalizeMCPOptions(entry) as MCPEntry;
}
+6 -1
View File
@@ -25,7 +25,7 @@ export interface MCPManager extends MCPRegistration {
}
export interface MCPRegistration {
registerMCP(registration: { [key: string | symbol]: MCPOptions }): Promise<void>;
registerMCP(registration: { [key: string | symbol]: MCPRegistrationOptions }): Promise<void>;
}
export type MCPOptions = {
@@ -39,9 +39,14 @@ export type MCPOptions = {
useUserContext?: boolean;
};
export type MCPRegistrationOptions = MCPOptions & {
fromFile?: boolean;
};
export type MCPEntry = MCPOptions & {
name: string;
enabled: boolean;
fromFile?: boolean;
};
export type MCPFilter = {
+1 -10
View File
@@ -18,7 +18,7 @@ import { resolve } from 'path';
import { Application } from './application';
import { getExposeChangelogUrl, getExposeReadmeUrl, InstallOptions } from './plugin-manager';
import { checkAndGetCompatible, getPluginBasePath } from './plugin-manager/utils';
import { SkillsLoader, ToolsLoader, AIEmployeeLoader, MCPLoader } from '@nocobase/ai';
import { SkillsLoader, ToolsLoader, AIEmployeeLoader } from '@nocobase/ai';
export interface PluginInterface {
beforeLoad?: () => void;
@@ -229,15 +229,6 @@ export abstract class Plugin<O = any> implements PluginInterface {
log: this.log,
});
await toolsLoader.load();
const mcpLoader = new MCPLoader(this.ai, {
pluginName: this.getName(),
scan: {
basePath,
pattern: ['mcp/*.ts', 'mcp/*.js', '!mcp/*.d.ts'],
},
log: this.log,
});
await mcpLoader.load();
const skillsLoader = new SkillsLoader(this.ai, {
pluginName: this.getName(),
scan: { basePath, pattern: ['**/skills/**/SKILLS.md'] },
@@ -35,7 +35,13 @@ import aiMcpClients from '../../../../collections/ai-mcp-clients';
import { useT } from '../../../locale';
import { MCPSettingsContext, unwrapResponseData } from './context';
import { MCPToolsList } from './MCPToolsList';
import { createMCPSchema, editMCPFormContentSchema, mcpSettingsSchema, viewMCPToolsContentSchema } from './schemas';
import {
createMCPSchema,
editMCPFormContentSchema,
mcpSettingsSchema,
readOnlyEditMCPFormContentSchema,
viewMCPToolsContentSchema,
} from './schemas';
type MCPTransport = 'stdio' | 'http' | 'sse';
@@ -53,7 +59,6 @@ type MCPVariableOption = Omit<Partial<DefaultOptionType>, 'children' | 'label' |
};
const transportOptions = [
{ label: 'Stdio', value: 'stdio' },
{ label: 'HTTP (Streamable)', value: 'http' },
{ label: 'HTTP + SSE (Legacy)', value: 'sse' },
];
@@ -141,14 +146,10 @@ const useCreateFormProps = () => {
const initialValues = useMemo(
() => ({
enabled: true,
transport: 'stdio',
transport: 'http',
useUserContext: false,
command: '',
url: '',
args: '',
env: [],
headers: [],
restart: {},
}),
[],
);
@@ -184,8 +185,17 @@ interface MCPRecord {
headers?: Record<string, string>;
restart?: Record<string, any>;
useUserContext?: boolean;
fromFile?: boolean;
}
const isManagedMCPRecord = (record?: MCPRecord) => record?.transport === 'stdio' || record?.fromFile === true;
const mcpRowSelection = {
type: 'checkbox' as const,
getCheckboxProps: (record: MCPRecord) => ({
disabled: isManagedMCPRecord(record),
}),
};
const useEditFormProps = () => {
const record = useCollectionRecordData<MCPRecord>();
const { visible } = useActionContext();
@@ -352,9 +362,12 @@ const useEditActionProps = () => {
const ensureConnectionBeforeSubmit = useEnsureConnectionBeforeSubmit();
const confirmSaveAfterFailedTest = useConfirmSaveAfterFailedTest();
const managed = isManagedMCPRecord(record);
return {
type: 'primary',
loading: rebuilding || testLoading,
disabled: managed,
async onClick() {
await form.submit();
const passed = await ensureConnectionBeforeSubmit(form.values);
@@ -384,6 +397,7 @@ const TestConnectionButton: React.FC = observer(
() => {
const form = useForm();
const api = useAPIClient();
const record = useCollectionRecordData<MCPRecord>();
const { setResult, loading, setLoading } = useContext(TestConnectionContext);
const t = useT();
@@ -391,10 +405,9 @@ const TestConnectionButton: React.FC = observer(
setLoading(true);
setResult(null);
try {
const values = sanitizeMCPValues(form.values);
const { data } = await api.resource('aiMcpClients').testConnection({
values,
});
const request =
record?.transport === 'stdio' ? { filterByTk: record.name } : { values: sanitizeMCPValues(form.values) };
const { data } = await api.resource('aiMcpClients').testConnection(request);
setResult(unwrapResponseData<TestConnectionResultData | null>({ data }, null));
} catch (error: any) {
setResult({
@@ -676,6 +689,7 @@ const MCPEditDrawerContent: React.FC = () => {
Space,
MCPVariableInput,
UserContextCheckbox,
ManagedMCPAlert,
}}
scope={{
t,
@@ -685,13 +699,12 @@ const MCPEditDrawerContent: React.FC = () => {
useCancelActionProps,
useEditActionProps,
}}
schema={editMCPFormContentSchema}
schema={isManagedMCPRecord(record) ? readOnlyEditMCPFormContentSchema : editMCPFormContentSchema}
/>
</TestConnectionContext.Provider>
</CollectionRecordProvider>
);
};
const MCPViewDrawerContent: React.FC = () => {
const t = useT();
const record = useCollectionRecordData<MCPRecord>();
@@ -707,9 +720,39 @@ const TransportTag: React.FC = () => {
const record = useCollectionRecordData<MCPRecord>();
const transport = record.transport;
const label = transportOptions.find((item) => item.value === transport)?.label || transport;
return <Tag color={transportColorMap[transport]}>{label}</Tag>;
return (
<Space>
<Tag color={transportColorMap[transport]}>{label}</Tag>
<SourceTag />
</Space>
);
};
const SourceTag: React.FC = () => {
const record = useCollectionRecordData<MCPRecord>();
const t = useT();
return record.fromFile ? <Tag>{t('File configuration')}</Tag> : null;
};
const ManagedMCPAlert: React.FC = () => {
const record = useCollectionRecordData<MCPRecord>();
const t = useT();
if (!isManagedMCPRecord(record)) {
return null;
}
return (
<Alert
type="info"
showIcon
message={t(
record.transport === 'stdio'
? 'Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.'
: 'This MCP configuration is managed by storage/ai/mcp/servers.json. Modify that file and reload the application.',
)}
style={{ marginBottom: 16 }}
/>
);
};
const EnabledSwitch: React.FC = observer(
() => {
const api = useAPIClient();
@@ -745,9 +788,13 @@ const EnabledSwitch: React.FC = observer(
const useMCPDestroyActionProps = () => {
const props = useDestroyActionProps();
const record = useCollectionRecordData<MCPRecord>();
const { rebuildClient, rebuilding } = useContext(MCPSettingsContext);
const managed = isManagedMCPRecord(record);
return {
...props,
disabled: managed,
style: managed ? { display: 'none' } : undefined,
loading: rebuilding,
async onClick(e?, callBack?) {
await props.onClick?.(e, callBack);
@@ -827,6 +874,7 @@ export const MCPSettings: React.FC = () => {
MCPVariableInput,
UserContextCheckbox,
TransportTag,
SourceTag,
EnabledSwitch,
}}
scope={{
@@ -840,6 +888,7 @@ export const MCPSettings: React.FC = () => {
useEditActionProps,
useMCPDestroyActionProps,
useMCPBulkDestroyActionProps,
mcpRowSelection,
}}
schema={mcpSettingsSchema}
/>
@@ -65,6 +65,9 @@ const createMCPFormProperties = (options: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("Transport") }}',
'x-decorator-props': {
tooltip: '{{ t("Stdio transport can only be configured in storage/ai/mcp/servers.json.") }}',
},
'x-component': 'Select',
enum: '{{ transportOptions }}',
required: true,
@@ -256,18 +259,48 @@ const createMCPFormProperties = (options: {
},
});
const createMCPProperties = createMCPFormProperties({ submitPropsHook: 'useCreateActionProps' });
delete createMCPProperties.command;
delete createMCPProperties.args;
delete createMCPProperties.env;
delete createMCPProperties.restart;
export const createMCPFormContentSchema = {
type: 'void',
properties: createMCPFormProperties({ submitPropsHook: 'useCreateActionProps' }),
properties: createMCPProperties,
};
const editMCPProperties = createMCPFormProperties({
disableName: true,
submitPropsHook: 'useEditActionProps',
footerComponent: 'Action.Drawer.FootBar',
});
export const editMCPFormContentSchema = {
type: 'void',
properties: createMCPFormProperties({
disableName: true,
submitPropsHook: 'useEditActionProps',
footerComponent: 'Action.Drawer.FootBar',
}),
properties: editMCPProperties,
};
const readOnlyEditMCPProperties = createMCPFormProperties({
disableName: true,
submitPropsHook: 'useEditActionProps',
footerComponent: 'Action.Drawer.FootBar',
});
Object.entries(readOnlyEditMCPProperties).forEach(([name, property]) => {
if (!['testResult', 'footer'].includes(name)) {
Object.assign(property, { 'x-read-pretty': true });
}
});
export const readOnlyEditMCPFormContentSchema = {
type: 'void',
properties: {
managedAlert: {
type: 'void',
'x-component': 'ManagedMCPAlert',
},
...readOnlyEditMCPProperties,
},
};
export const viewMCPToolsContentSchema = {
@@ -384,9 +417,7 @@ export const mcpSettingsSchema = {
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'name',
rowSelection: {
type: 'checkbox',
},
rowSelection: '{{ mcpRowSelection }}',
},
properties: {
column1: {
@@ -33,6 +33,11 @@ export default {
'x-component': 'Input.TextArea',
},
},
{
name: 'fromFile',
type: 'boolean',
defaultValue: false,
},
{
name: 'enabled',
type: 'boolean',
@@ -240,6 +240,11 @@
"Temperature description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"Thinking process": "Thinking process",
"Test flight": "Test flight",
"Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.": "Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.",
"This MCP configuration is managed by storage/ai/mcp/servers.json. Modify that file and reload the application.": "This MCP configuration is managed by storage/ai/mcp/servers.json. Modify that file and reload the application.",
"MCP configuration not found": "MCP configuration not found",
"MCP stdio configuration is incomplete": "MCP stdio configuration is incomplete",
"File configuration": "File configuration",
"Testing connection...": "Testing connection...",
"Text": "Text",
"The parameters required by the tool": "Parameters required by the tool",
@@ -468,5 +473,6 @@
"The MCP server uses current user variables and the connection test failed. Do you want to save it anyway?": "The MCP server uses current user variables and the connection test failed. Do you want to save it anyway?",
"Invalid attachment": "Invalid attachment",
"Attachment not found": "Attachment not found",
"Stdio transport can only be configured in storage/ai/mcp/servers.json.": "Stdio transport can only be configured in storage/ai/mcp/servers.json.",
"Save anyway": "Save anyway"
}
@@ -241,6 +241,11 @@
"Temperature description": "采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。",
"Thinking process": "思考过程",
"Test flight": "可用性测试",
"Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.": "Stdio MCP 配置由 storage/ai/mcp/servers.json 管理,请修改该文件并重新加载应用。",
"This MCP configuration is managed by storage/ai/mcp/servers.json. Modify that file and reload the application.": "此 MCP 配置由 storage/ai/mcp/servers.json 管理,请修改该文件并重新加载应用。",
"MCP configuration not found": "未找到 MCP 配置",
"MCP stdio configuration is incomplete": "MCP stdio 配置不完整",
"File configuration": "文件配置",
"Testing connection...": "正在测试连接...",
"Text": "文本",
"The parameters required by the tool": "工具所需的参数",
@@ -474,5 +479,6 @@
"The MCP server uses current user variables and the connection test failed. Do you want to save it anyway?": "该 MCP 服务使用当前用户变量,且连接测试失败。是否仍然保存?",
"Invalid attachment": "无效的附件",
"Attachment not found": "附件不存在",
"Stdio transport can only be configured in storage/ai/mcp/servers.json.": "Stdio 类型只能在 storage/ai/mcp/servers.json 中配置。",
"Save anyway": "仍然保存"
}
@@ -10,7 +10,7 @@
import { createMockServer, MockServer } from '@nocobase/test';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import PluginAIServer from '../plugin';
import { aiMcpClients } from '../resource/aiMcpClients';
import { aiMcpClients, guardMCPClientMutations } from '../resource/aiMcpClients';
describe('MCP client database events', () => {
let app: MockServer;
@@ -24,7 +24,7 @@ describe('MCP client database events', () => {
await app.pm.enable('ai');
plugin = app.pm.get('ai') as PluginAIServer;
clearUserContextCache = vi.fn().mockResolvedValue(undefined);
plugin.ai.mcpManager.clearUserContextCache = clearUserContextCache;
plugin.ai.mcpManager.clearUserContextCache = clearUserContextCache as () => Promise<void>;
});
beforeEach(() => {
@@ -143,7 +143,7 @@ describe('aiMcpClients resource actions', () => {
body: undefined,
};
const next = vi.fn().mockResolvedValue(undefined);
const action = aiMcpClients.actions?.listTools as (ctx: typeof ctx, next: typeof next) => Promise<void>;
const action = aiMcpClients.actions?.listTools as (actionCtx: typeof ctx, actionNext: typeof next) => Promise<void>;
await action(ctx, next);
@@ -151,4 +151,206 @@ describe('aiMcpClients resource actions', () => {
expect(ctx.body).toBe(tools);
expect(next).toHaveBeenCalledTimes(1);
});
it('uses only the saved database configuration when testing stdio', async () => {
const testConnection = vi.fn().mockResolvedValue({ success: true });
const record = {
toJSON: () => ({
name: 'trusted-stdio',
transport: 'stdio',
command: 'trusted-command',
args: ['trusted-arg'],
env: { TRUSTED: 'true' },
restart: { enabled: true },
}),
};
const ctx = {
action: {
params: {
filterByTk: 'trusted-stdio',
values: {
transport: 'stdio',
command: 'malicious-command',
args: ['malicious-arg'],
env: { MALICIOUS: 'true' },
},
},
},
db: { getRepository: vi.fn(() => ({ findOne: vi.fn().mockResolvedValue(record) })) },
app: { pm: { get: vi.fn(() => ({ ai: { mcpManager: { testConnection } } })) } },
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
body: undefined,
};
const next = vi.fn().mockResolvedValue(undefined);
const action = aiMcpClients.actions?.testConnection as (
actionCtx: typeof ctx,
actionNext: typeof next,
) => Promise<void>;
await action(ctx, next);
expect(testConnection).toHaveBeenCalledWith(
{
transport: 'stdio',
command: 'trusted-command',
args: ['trusted-arg'],
env: { TRUSTED: 'true' },
url: undefined,
headers: undefined,
restart: { enabled: true },
useUserContext: undefined,
},
ctx,
);
expect(testConnection).not.toHaveBeenCalledWith(expect.objectContaining({ command: 'malicious-command' }), ctx);
});
it('rejects untrusted stdio test values without a saved stdio record', async () => {
const testConnection = vi.fn();
const ctx = {
action: { params: { values: { transport: 'stdio', command: 'malicious-command' } } },
db: { getRepository: vi.fn(() => ({ findOne: vi.fn() })) },
app: { pm: { get: vi.fn(() => ({ ai: { mcpManager: { testConnection } } })) } },
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
body: undefined,
};
const action = aiMcpClients.actions?.testConnection as (
actionCtx: typeof ctx,
actionNext: () => Promise<void>,
) => Promise<void>;
await expect(action(ctx, vi.fn())).rejects.toMatchObject({ status: 400 });
expect(testConnection).not.toHaveBeenCalled();
});
it('returns 404 for a missing filterByTk record and never falls back to stdio request values', async () => {
const testConnection = vi.fn();
const ctx = {
action: {
params: { filterByTk: 'missing', values: { transport: 'stdio', command: 'malicious-command' } },
},
db: { getRepository: vi.fn(() => ({ findOne: vi.fn().mockResolvedValue(null) })) },
app: { pm: { get: vi.fn(() => ({ ai: { mcpManager: { testConnection } } })) } },
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
body: undefined,
};
const action = aiMcpClients.actions?.testConnection as (
actionCtx: typeof ctx,
actionNext: () => Promise<void>,
) => Promise<void>;
await expect(action(ctx, vi.fn())).rejects.toMatchObject({ status: 404 });
expect(testConnection).not.toHaveBeenCalled();
});
it('keeps HTTP form-value connection tests unchanged', async () => {
const result = { success: true };
const testConnection = vi.fn().mockResolvedValue(result);
const values = { transport: 'http' as const, url: 'https://example.com/mcp', headers: { Authorization: 'test' } };
const ctx = {
action: { params: { values } },
db: { getRepository: vi.fn(() => ({ findOne: vi.fn() })) },
app: { pm: { get: vi.fn(() => ({ ai: { mcpManager: { testConnection } } })) } },
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
body: undefined,
};
const next = vi.fn().mockResolvedValue(undefined);
const action = aiMcpClients.actions?.testConnection as (
actionCtx: typeof ctx,
actionNext: typeof next,
) => Promise<void>;
await action(ctx, next);
expect(testConnection).toHaveBeenCalledWith(expect.objectContaining(values), ctx);
expect(ctx.body).toBe(result);
});
it.each([
['update', { transport: 'stdio', fromFile: false }],
['destroy', { transport: 'stdio', fromFile: null }],
['update', { transport: 'http', fromFile: true }],
])('blocks managed MCP %s mutations', async (actionName, recordValues) => {
const next = vi.fn();
const ctx = {
action: {
resourceName: 'aiMcpClients',
actionName,
params: { filterByTk: 'managed', values: {} },
},
db: {
getRepository: vi.fn(() => ({
find: vi.fn().mockResolvedValue([{ get: (key: string) => recordValues[key] }]),
})),
},
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
};
await expect(guardMCPClientMutations(ctx as never, next)).rejects.toMatchObject({ status: 400 });
expect(next).not.toHaveBeenCalled();
});
it('allows enabled-only updates for managed MCP records', async () => {
const next = vi.fn();
const ctx = {
action: {
resourceName: 'aiMcpClients',
actionName: 'update',
params: { filterByTk: 'managed', values: { enabled: false } },
},
db: {
getRepository: vi.fn(() => ({
find: vi.fn().mockResolvedValue([{ get: (key: string) => (key === 'transport' ? 'stdio' : false) }]),
})),
},
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
};
await guardMCPClientMutations(ctx as never, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('rejects stdio creation and strips forged source markers from HTTP creation', async () => {
const stdioContext = {
action: {
resourceName: 'aiMcpClients',
actionName: 'create',
params: { values: { transport: 'stdio', fromFile: true } },
},
t: (message: string) => message,
throw: (status: number, message: string) => {
throw Object.assign(new Error(message), { status });
},
};
await expect(guardMCPClientMutations(stdioContext as never, vi.fn())).rejects.toMatchObject({ status: 400 });
const values = { transport: 'http', fromFile: true };
const next = vi.fn();
const httpContext = {
action: { resourceName: 'aiMcpClients', actionName: 'create', params: { values } },
t: (message: string) => message,
};
await guardMCPClientMutations(httpContext as never, next);
expect(values).toEqual({ transport: 'http' });
expect(next).toHaveBeenCalledTimes(1);
});
});
@@ -35,7 +35,7 @@ import { ollamaProviderOptions } from './llm-providers/ollama';
import { BuiltInManager } from './manager/built-in-manager';
import { AIContextDatasourceManager } from './manager/ai-context-datasource-manager';
import { aiContextDatasources } from './resource/aiContextDatasources';
import aiMcpClients from './resource/aiMcpClients';
import aiMcpClients, { guardMCPClientMutations } from './resource/aiMcpClients';
import { createWorkContextHandler } from './manager/work-context-handler';
import { AICodingManager } from './manager/ai-coding-manager';
import { kimiProviderOptions } from './llm-providers/kimi';
@@ -57,7 +57,8 @@ import {
import { KnowledgeBaseManager } from './ai-employees/ai-knowledge-base';
import { LLMStreamCachedManager } from './manager/llm-stream-manager';
import { appendAIFileAttachmentSource } from './attachments';
import { storagePathJoin } from '@nocobase/utils';
import { MCPLoader } from '@nocobase/ai';
type MCPClientModel = Model<{ useUserContext?: boolean }>;
type TransactionOptions = {
transaction?: Transaction;
@@ -113,6 +114,11 @@ export class PluginAIServer extends Plugin {
this.app.on('afterStart', async () => {
await this.ai.skillsManager.init();
await this.ai.employeeManager.init();
const mcpLoader = new MCPLoader(this.ai, {
serversPath: storagePathJoin('ai', 'mcp', 'servers.json'),
log: this.log,
});
await mcpLoader.load();
await this.ai.mcpManager.init();
});
this.app.on('afterUpgrade', async () => {
@@ -209,6 +215,8 @@ export class PluginAIServer extends Plugin {
this.app.resourceManager.define(aiContextDatasources);
this.app.resourceManager.define(aiMcpClients);
this.app.resourceManager.use(guardMCPClientMutations, { before: 'createMiddleware' });
this.app.resourceManager.use(
async (ctx, next) => {
const { resourceName, actionName } = ctx.action;
@@ -7,17 +7,100 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { Context, Next } from '@nocobase/actions';
import { ResourceOptions } from '@nocobase/resourcer';
import { MCPOptions, MCPTestResult } from '@nocobase/ai';
import type { Permission } from '@nocobase/ai';
type MCPRecordData = MCPOptions & {
name: string;
};
const getConnectionOptions = (values: Partial<MCPOptions>): MCPOptions => ({
transport: values.transport as MCPOptions['transport'],
command: values.command,
args: values.args,
env: values.env,
url: values.url,
headers: values.headers,
restart: values.restart,
useUserContext: values.useUserContext,
});
export async function guardMCPClientMutations(ctx: Context, next: Next) {
const { resourceName, actionName, params } = ctx.action;
if (resourceName !== 'aiMcpClients' || !['create', 'update', 'destroy'].includes(actionName)) {
await next();
return;
}
const values = params.values || {};
if (Object.prototype.hasOwnProperty.call(values, 'fromFile')) {
delete values.fromFile;
}
const managedMessage = ctx.t(
'Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.',
);
if ((actionName === 'create' || actionName === 'update') && values.transport === 'stdio') {
ctx.throw(400, managedMessage);
}
if (actionName === 'update' || actionName === 'destroy') {
const repository = ctx.db.getRepository('aiMcpClients');
const query = params.filterByTk != null ? { filterByTk: params.filterByTk } : { filter: params.filter };
const records = await repository.find(query);
const managedRecords = records.filter(
(record) => record.get('transport') === 'stdio' || record.get('fromFile') === true,
);
const isEnabledOnlyUpdate =
actionName === 'update' &&
Object.keys(values).length === 1 &&
Object.prototype.hasOwnProperty.call(values, 'enabled');
if (managedRecords.length > 0 && !isEnabledOnlyUpdate) {
ctx.throw(400, managedMessage);
}
}
await next();
}
export const aiMcpClients: ResourceOptions = {
name: 'aiMcpClients',
actions: {
testConnection: async (ctx, next) => {
const values = ctx.action.params.values as MCPOptions;
const { filterByTk, values } = ctx.action.params;
const submittedValues = values as Partial<MCPOptions> | undefined;
const repository = ctx.db.getRepository('aiMcpClients');
if (!values) {
if (filterByTk != null && filterByTk !== '') {
const record = await repository.findOne({ filterByTk });
if (!record) {
ctx.throw(404, ctx.t('MCP configuration not found'));
}
const recordData = record.toJSON() as MCPRecordData;
if (recordData.transport === 'stdio') {
if (!recordData.command) {
ctx.throw(400, ctx.t('MCP stdio configuration is incomplete'));
}
const plugin = ctx.app.pm.get('ai');
ctx.body = await plugin.ai.mcpManager.testConnection(getConnectionOptions(recordData), ctx);
await next();
return;
}
if (submittedValues?.transport === 'stdio') {
ctx.throw(
400,
ctx.t(
'Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.',
),
);
}
}
if (!submittedValues) {
ctx.body = {
success: false,
error: 'No configuration provided',
@@ -26,10 +109,17 @@ export const aiMcpClients: ResourceOptions = {
return;
}
const plugin = ctx.app.pm.get('ai');
const result = await plugin.ai.mcpManager.testConnection(values, ctx);
if (submittedValues.transport === 'stdio') {
ctx.throw(
400,
ctx.t(
'Stdio MCP configurations are managed by storage/ai/mcp/servers.json. Modify that file and reload the application.',
),
);
}
ctx.body = result;
const plugin = ctx.app.pm.get('ai');
ctx.body = await plugin.ai.mcpManager.testConnection(getConnectionOptions(submittedValues), ctx);
await next();
},
rebuildClient: async (ctx, next) => {