mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(plugin-ai): secure file-managed MCP servers
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
+63
-14
@@ -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": "仍然保存"
|
||||
}
|
||||
|
||||
+205
-3
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user