fix(mcp-server): optimize tool naming and responses (#9000)

* fix(mcp-server): sanitize MCP tool input schema

* fix(mcp): normalize generated tool output for browsing

* refactor(mcp): use McpToolsManager in post processors
This commit is contained in:
YANG QIA
2026-03-30 21:57:44 +08:00
committed by GitHub
parent c5a8cb62f5
commit 0d1ddbe30f
13 changed files with 741 additions and 13 deletions
+1 -1
View File
@@ -536,4 +536,4 @@
}
]
}
]
]
+50
View File
@@ -13,6 +13,10 @@ export type McpTool = {
name: string;
description: string;
inputSchema?: any;
resourceName?: string;
actionName?: string;
path?: string;
method?: string;
call: (args: Record<string, any>, context?: McpToolCallContext) => Promise<any>;
};
@@ -21,8 +25,29 @@ export type McpToolCallContext = {
headers?: Record<string, string | string[] | undefined>;
};
export type McpToolResultPostProcessorContext = {
tool: McpTool;
args: Record<string, any>;
callContext?: McpToolCallContext;
response?: {
statusCode?: number;
headers?: Record<string, any>;
body?: any;
};
};
export type McpToolResultPostProcessor = (
result: any,
context: McpToolResultPostProcessorContext,
) => any | Promise<any>;
export class McpToolsManager {
private tools = new Registry<McpTool>();
private resultPostProcessors = new Map<string, McpToolResultPostProcessor[]>();
private getActionKey(resourceName: string, actionName: string) {
return `${resourceName}:${actionName}`;
}
registerTools(tools: McpTool[]) {
for (const tool of tools) {
@@ -30,6 +55,31 @@ export class McpToolsManager {
}
}
registerToolResultPostProcessor(resourceName: string, actionName: string, processor: McpToolResultPostProcessor) {
const key = this.getActionKey(resourceName, actionName);
const processors = this.resultPostProcessors.get(key) || [];
processors.push(processor);
this.resultPostProcessors.set(key, processors);
}
async postProcessToolResult(tool: McpTool, result: any, context: Omit<McpToolResultPostProcessorContext, 'tool'>) {
if (!tool.resourceName || !tool.actionName) {
return result;
}
const processors = this.resultPostProcessors.get(this.getActionKey(tool.resourceName, tool.actionName)) || [];
let current = result;
for (const processor of processors) {
current = await processor(current, {
...context,
tool,
});
}
return current;
}
listTools() {
return [...this.tools.getValues()];
}
@@ -0,0 +1,78 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { simplifyCollectionsListResult, simplifyFieldsListResult } from '../mcp-post-processors';
describe('simplifyCollectionsListResult', () => {
it('should compress collection metadata for MCP browsing', () => {
const result = simplifyCollectionsListResult({
data: [
{
key: 'vi5fvvon9vo',
name: 'posts',
title: 'Posts',
filterTargetKey: 'name',
fields: [{ name: 'id' }, { name: 'title' }, { name: 'content' }],
},
],
meta: {
count: 1,
},
});
expect(result).toEqual({
data: [
{
key: 'vi5fvvon9vo',
name: 'posts',
title: 'Posts',
description: undefined,
},
],
meta: {
count: 1,
},
nextActions: [
'Use collections:get with filterByTk=<collectionName> to inspect one collection in detail.',
'Use collections.fields:list with sourceId=<collectionName> to inspect fields when needed.',
],
});
});
it('should compress field metadata for MCP browsing', () => {
const result = simplifyFieldsListResult([
{
key: 'field_1',
name: 'title',
type: 'string',
uiSchema: {
title: 'Title',
},
description: 'Post title',
collectionName: 'posts',
interface: 'input',
},
]);
expect(result).toEqual({
data: [
{
key: 'field_1',
name: 'title',
type: 'string',
title: 'Title',
description: 'Post title',
collectionName: 'posts',
},
],
meta: undefined,
});
});
});
@@ -0,0 +1,70 @@
/**
* 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 type { McpToolsManager } from '@nocobase/ai';
function toArray(value: any) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.data)) {
return value.data;
}
return [];
}
function pickCollectionSummary(item: Record<string, any>) {
return {
key: item?.key,
name: item?.name,
title: item?.title,
description: item?.description,
};
}
function pickFieldSummary(item: Record<string, any>) {
return {
key: item?.key,
name: item?.name,
type: item?.type,
title: item?.uiSchema?.title,
description: item?.description,
collectionName: item?.collectionName,
};
}
export function simplifyCollectionsListResult(result: any) {
const items = toArray(result);
return {
data: items.map((item) => pickCollectionSummary(item)),
meta: result?.meta,
nextActions: [
'Use collections:get with filterByTk=<collectionName> to inspect one collection in detail.',
'Use collections.fields:list with sourceId=<collectionName> to inspect fields when needed.',
],
};
}
export function simplifyFieldsListResult(result: any) {
const items = toArray(result);
return {
data: items.map((item) => pickFieldSummary(item)),
meta: result?.meta,
};
}
export function registerDataSourceMainMcpPostProcessors(manager: McpToolsManager) {
manager.registerToolResultPostProcessor('collections', 'list', simplifyCollectionsListResult);
manager.registerToolResultPostProcessor('collections', 'listMeta', simplifyCollectionsListResult);
manager.registerToolResultPostProcessor('collections.fields', 'list', simplifyFieldsListResult);
}
@@ -37,6 +37,7 @@ import { CollectionModel, FieldModel } from './models';
import collectionActions from './resourcers/collections';
import viewResourcer from './resourcers/views';
import mainDataSourceResource from './resourcers/main-data-source';
import { registerDataSourceMainMcpPostProcessors } from './mcp-post-processors';
import { ColumnsDescription } from 'sequelize';
import { PRESET_FIELDS_INTERFACES } from './constants';
import { Schema } from '@formily/json-schema';
@@ -472,6 +473,7 @@ export class PluginDataSourceMainServer extends Plugin {
this.db.getRepository<CollectionRepository>('collections').setApp(this.app);
this.registerErrorHandler();
registerDataSourceMainMcpPostProcessors(this.ai.mcpToolsManager);
this.app.resourceManager.use(async function mergeReverseFieldWhenSaveCollectionField(ctx, next) {
if (ctx.action.resourceName === 'collections.fields' && ['create', 'update'].includes(ctx.action.actionName)) {
@@ -0,0 +1,164 @@
/**
* 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 { describe, expect, it } from 'vitest';
import {
simplifyDataSourceCollectionsListResult,
simplifyDataSourceFieldsListResult,
simplifyDataSourceListResult,
} from '../mcp-post-processors';
describe('simplifyDataSourceListResult', () => {
it('should compress data source list payloads for MCP browsing', () => {
const result = simplifyDataSourceListResult({
data: [
{
key: 'analytics',
displayName: 'Analytics',
description: 'BI database',
status: 'loaded',
type: 'postgres',
},
],
meta: {
count: 1,
},
});
expect(result).toEqual({
data: [
{
displayName: 'Analytics',
key: 'analytics',
type: 'postgres',
status: 'loaded',
},
],
meta: {
count: 1,
},
});
});
it('should compress nested collections and fields in data source list payloads', () => {
const result = simplifyDataSourceListResult({
data: [
{
key: 'analytics',
displayName: 'Analytics',
collections: [
{
name: 'roles',
title: 'roles',
description: null,
fields: [
{
name: 'createdAt',
type: 'datetimeTz',
description: null,
uiSchema: {
title: 'createdAt',
},
},
],
},
],
},
],
});
expect(result).toEqual({
data: [
{
displayName: 'Analytics',
key: 'analytics',
type: undefined,
status: undefined,
collections: [
{
name: 'roles',
title: 'roles',
description: undefined,
dataSourceKey: 'analytics',
fields: [
{
name: 'createdAt',
type: 'datetimeTz',
title: 'createdAt',
description: null,
dataSourceKey: 'analytics',
collectionName: 'roles',
},
],
},
],
},
],
meta: undefined,
});
});
it('should compress external collection list payloads for MCP browsing', () => {
const result = simplifyDataSourceCollectionsListResult([
{
name: 'orders',
title: 'Orders',
description: 'Order records',
dataSourceKey: 'analytics',
fields: [{ name: 'id' }],
},
]);
expect(result).toEqual({
data: [
{
name: 'orders',
title: 'Orders',
description: 'Order records',
dataSourceKey: 'analytics',
},
],
meta: undefined,
});
});
it('should compress external field list payloads for MCP browsing', () => {
const result = simplifyDataSourceFieldsListResult(
[
{
name: 'amount',
type: 'decimal',
description: 'Order amount',
uiSchema: {
title: 'Amount',
},
},
],
{
args: {
associatedIndex: 'analytics.orders',
},
},
);
expect(result).toEqual({
data: [
{
name: 'amount',
type: 'decimal',
title: 'Amount',
description: 'Order amount',
dataSourceKey: 'analytics',
collectionName: 'orders',
},
],
meta: undefined,
});
});
});
@@ -0,0 +1,153 @@
/**
* 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 type { McpToolsManager } from '@nocobase/ai';
function toArray(value: any) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.data)) {
return value.data;
}
return [];
}
function parseAssociatedIndex(value: unknown) {
if (typeof value !== 'string') {
return {};
}
const separatorIndex = value.indexOf('.');
if (separatorIndex === -1) {
return {};
}
return {
dataSourceKey: value.slice(0, separatorIndex),
collectionName: value.slice(separatorIndex + 1),
};
}
function pickDataSourceSummary(item: Record<string, any>) {
const summary: Record<string, any> = {
displayName: item?.displayName,
key: item?.key,
type: item?.type,
status: item?.status,
};
if (Array.isArray(item?.collections)) {
summary.collections = item.collections.map((collection: Record<string, any>) => {
const nextCollection: Record<string, any> = pickCollectionSummary({
...collection,
dataSourceKey: collection?.dataSourceKey ?? item?.key,
});
if (Array.isArray(collection?.fields)) {
nextCollection.fields = collection.fields.map((field: Record<string, any>) =>
pickRemoteFieldSummary(field, {
dataSourceKey: collection?.dataSourceKey ?? item?.key,
collectionName: collection?.name,
}),
);
}
return nextCollection;
});
}
return summary;
}
function pickCollectionSummary(item: Record<string, any>) {
return {
name: item?.name,
title: item?.title ?? item?.options?.title ?? item?.displayName,
description: item?.description ?? item?.options?.description,
dataSourceKey: item?.dataSourceKey,
};
}
function pickFieldSummary(item: Record<string, any>) {
return {
name: item?.name,
type: item?.type,
title: item?.uiSchema?.title,
description: item?.description,
dataSourceKey: item?.dataSourceKey,
collectionName: item?.collectionName,
};
}
function pickRemoteFieldSummary(
item: Record<string, any>,
defaults: {
dataSourceKey?: string;
collectionName?: string;
},
) {
return {
name: item?.name,
type: item?.type,
title: item?.uiSchema?.title,
description: item?.description,
dataSourceKey: item?.dataSourceKey,
collectionName: item?.collectionName,
...defaults,
};
}
export function simplifyDataSourceListResult(result: any) {
const items = toArray(result);
return {
data: items.map((item) => pickDataSourceSummary(item)),
meta: result?.meta,
};
}
export function simplifyDataSourceCollectionsListResult(result: any) {
const items = toArray(result);
return {
data: items.map((item) => pickCollectionSummary(item)),
meta: result?.meta,
};
}
export function simplifyDataSourceFieldsListResult(
result: any,
options?: {
args?: Record<string, any>;
},
) {
const items = toArray(result);
const defaults = parseAssociatedIndex(options?.args?.associatedIndex);
return {
data: items.map((item) =>
item?.dataSourceKey || item?.collectionName ? pickFieldSummary(item) : pickRemoteFieldSummary(item, defaults),
),
meta: result?.meta,
};
}
export function registerDataSourceManagerMcpPostProcessors(manager: McpToolsManager) {
manager.registerToolResultPostProcessor('dataSources', 'list', simplifyDataSourceListResult);
manager.registerToolResultPostProcessor('dataSources', 'listEnabled', simplifyDataSourceListResult);
manager.registerToolResultPostProcessor('dataSources.collections', 'list', simplifyDataSourceCollectionsListResult);
manager.registerToolResultPostProcessor('dataSourcesCollections.fields', 'list', (result, context) =>
simplifyDataSourceFieldsListResult(result, {
args: context?.args,
}),
);
}
@@ -25,6 +25,7 @@ import { DataSourcesRolesModel } from './models/data-sources-roles-model';
import { mergeRole } from '@nocobase/acl';
import { loadDataSourceTablesIntoCollections } from './middlewares/load-tables';
import { Collection } from '@nocobase/database';
import { registerDataSourceManagerMcpPostProcessors } from './mcp-post-processors';
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
@@ -356,6 +357,7 @@ export class PluginDataSourceManagerServer extends Plugin {
});
this.app.resourceManager.use(loadDataSourceTablesIntoCollections);
registerDataSourceManagerMcpPostProcessors(this.ai.mcpToolsManager);
this.app.use(async function handleAppendDataSourceCollection(ctx, next) {
await next();
@@ -0,0 +1,67 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { McpToolsManager } from '@nocobase/ai';
import { createCrudTool } from '../crud-tool';
describe('createCrudTool', () => {
it('should post-process CRUD list results', async () => {
const manager = new McpToolsManager();
manager.registerToolResultPostProcessor('dataSources', 'list', (result) => {
return {
data: result.data.map((item) => ({
key: item.key,
displayName: item.displayName,
})),
};
});
const tool = createCrudTool({
app: {
callback: () => (req, res) => {
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
data: [
{
key: 'main',
displayName: 'Main',
status: 'loaded',
},
],
}),
);
},
resourcer: { options: { prefix: '/api' } },
},
mcpToolsManager: manager,
});
const result = await tool.call(
{
resource: 'dataSources',
action: 'list',
},
{
headers: {},
},
);
expect(result).toEqual({
data: [
{
key: 'main',
displayName: 'Main',
},
],
});
});
});
@@ -8,7 +8,9 @@
*/
import { describe, expect, it } from 'vitest';
import { McpToolsManager } from '@nocobase/ai';
import { sanitizeJsonSchemaForOpenAITools } from '../schema-utils';
import { normalizeMcpToolName, parseResourceActionFromPath } from '../mcp-tools';
describe('sanitizeJsonSchemaForOpenAITools', () => {
it('should remove null type and nullable markers recursively', () => {
@@ -69,4 +71,64 @@ describe('sanitizeJsonSchemaForOpenAITools', () => {
},
});
});
it('should parse resource and action names from action paths', () => {
expect(parseResourceActionFromPath('/collections:list')).toEqual({
resourceName: 'collections',
actionName: 'list',
});
expect(parseResourceActionFromPath('/collections/{filterByTk}/fields:list')).toEqual({
resourceName: 'collections.fields',
actionName: 'list',
});
});
it('should apply registered post processors by resource and action', async () => {
const manager = new McpToolsManager();
manager.registerToolResultPostProcessor('collections', 'list', (result) => {
return {
...result,
compressed: true,
};
});
const output = await manager.postProcessToolResult(
{
name: 'collections_list',
description: 'list collections',
resourceName: 'collections',
actionName: 'list',
call: async () => null,
},
{
data: [],
},
{
args: {},
},
);
expect(output).toEqual({
data: [],
compressed: true,
});
});
it('should normalize generated tool names without method prefixes', () => {
expect(
normalizeMcpToolName({
name: 'PostCollectionsFields_destroy',
pathTemplate: '/collections/{filterByTk}/fields:destroy',
}),
).toBe('CollectionsFieldsDestroy');
expect(
normalizeMcpToolName({
name: 'GetCollections_listMeta',
pathTemplate: '/collections:listMeta',
}),
).toBe('CollectionsListMeta');
});
});
@@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { McpTool, McpToolCallContext } from '@nocobase/ai';
import type { McpTool, McpToolCallContext, McpToolsManager } from '@nocobase/ai';
import inject from 'light-my-request';
type CrudAction = 'list' | 'get' | 'create' | 'update' | 'destroy';
@@ -156,11 +156,22 @@ function buildHeaders(args: BusinessTableCrudArgs, context?: McpToolCallContext)
return headers;
}
function createCrudActionToolMeta(args: BusinessTableCrudArgs): McpTool {
return {
name: 'crud',
description: 'Generic CRUD fallback tool',
resourceName: args.resource,
actionName: args.action,
call: async () => null,
};
}
export function createCrudTool(options: {
app: {
callback: () => any;
resourcer: { options?: { prefix?: string } };
};
mcpToolsManager: McpToolsManager;
}): McpTool {
const prefix = options.app.resourcer.options?.prefix || '/api';
@@ -304,7 +315,15 @@ export function createCrudTool(options: {
);
}
return body;
return options.mcpToolsManager.postProcessToolResult(createCrudActionToolMeta(typedArgs), body, {
args: typedArgs,
callContext: context,
response: {
statusCode: response.statusCode,
headers: response.headers,
body,
},
});
},
};
}
@@ -9,11 +9,13 @@
import type { McpTool } from '@nocobase/ai';
import type { McpToolCallContext } from '@nocobase/ai';
import type { McpToolsManager } from '@nocobase/ai';
import type { OpenAPIV3 } from 'openapi-types';
import { requireModule } from '@nocobase/utils';
import { merge as deepmerge } from '@nocobase/utils';
import inject from 'light-my-request';
import { sanitizeJsonSchemaForOpenAITools } from './schema-utils';
import { Application } from '@nocobase/server';
type OpenAPIDocument = OpenAPIV3.Document;
type McpToolDefinitionWithBaseUrl = import('openapi-mcp-generator').McpToolDefinition & { baseUrl?: string };
@@ -170,6 +172,49 @@ function joinUrl(baseUrl: string, path: string) {
return `${normalizedBase}${normalizedPath}`;
}
export function parseResourceActionFromPath(pathTemplate: string) {
const normalizedPath = pathTemplate.replace(/^\/+/, '');
const separatorIndex = normalizedPath.lastIndexOf(':');
if (separatorIndex === -1) {
return {};
}
const resourcePath = normalizedPath.slice(0, separatorIndex);
const actionName = normalizedPath.slice(separatorIndex + 1);
const resourceName = resourcePath
.split('/')
.filter((segment) => segment && !segment.startsWith('{'))
.join('.');
if (!resourceName || !actionName) {
return {};
}
return {
resourceName,
actionName,
};
}
function toPascalCase(value: string) {
return value
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('');
}
export function normalizeMcpToolName(tool: Pick<McpToolDefinitionWithBaseUrl, 'name' | 'pathTemplate'>) {
const actionMeta = parseResourceActionFromPath(tool.pathTemplate);
if (actionMeta.resourceName && actionMeta.actionName) {
return toPascalCase(`${actionMeta.resourceName}_${actionMeta.actionName}`);
}
return toPascalCase(tool.name);
}
function buildQueryValue(value: any) {
if (typeof value === 'undefined') {
return undefined;
@@ -260,12 +305,7 @@ function buildRequest(
}
export async function collectMcpToolsFromSwagger(options: {
app: {
db: any;
version: { get: () => Promise<string> };
resourcer: { options?: { prefix?: string } };
callback: () => any;
};
app: Application;
packagePatterns?: string[];
}): Promise<McpTool[]> {
const { app, packagePatterns } = options;
@@ -312,9 +352,14 @@ export async function collectMcpToolsFromSwagger(options: {
const mcpTools = (await getToolsFromOpenApi(swagger)) as McpToolDefinitionWithBaseUrl[];
return mcpTools.map((tool) => {
return {
name: tool.name,
const actionMeta = parseResourceActionFromPath(tool.pathTemplate);
const mcpTool: McpTool = {
name: normalizeMcpToolName(tool),
description: tool.description,
resourceName: actionMeta.resourceName,
actionName: actionMeta.actionName,
path: tool.pathTemplate,
method: tool.method.toUpperCase(),
inputSchema: sanitizeJsonSchemaForOpenAITools(tool.inputSchema),
call: async (args: Record<string, any>, context?: McpToolCallContext) => {
const request = buildRequest(tool, args, context);
@@ -352,8 +397,18 @@ export async function collectMcpToolsFromSwagger(options: {
);
}
return body;
return app.aiManager.mcpToolsManager.postProcessToolResult(mcpTool, body, {
args,
callContext: context,
response: {
statusCode: response.statusCode,
headers: response.headers,
body,
},
});
},
};
return mcpTool;
});
}
@@ -138,7 +138,13 @@ export class PluginMcpServerServer extends Plugin {
app: this.app,
packagePatterns,
})
.then((apiTools) => [...apiTools, createCrudTool({ app: this.app })])
.then((apiTools) => [
...apiTools,
createCrudTool({
app: this.app,
mcpToolsManager: this.ai.mcpToolsManager,
}),
])
.catch((error) => {
this.mcpToolsCache.delete(cacheKey);
throw error;