mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
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:
@@ -536,4 +536,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
+78
@@ -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)) {
|
||||
|
||||
+164
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+153
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user