diff --git a/docs/docs/en/interface-builder/_meta.json b/docs/docs/en/interface-builder/_meta.json index 64eb3d1d00a..4cad990fff8 100644 --- a/docs/docs/en/interface-builder/_meta.json +++ b/docs/docs/en/interface-builder/_meta.json @@ -536,4 +536,4 @@ } ] } -] \ No newline at end of file +] diff --git a/packages/core/ai/src/mcp-tools-manager.ts b/packages/core/ai/src/mcp-tools-manager.ts index c06a57e2e9d..f00d0df9453 100644 --- a/packages/core/ai/src/mcp-tools-manager.ts +++ b/packages/core/ai/src/mcp-tools-manager.ts @@ -13,6 +13,10 @@ export type McpTool = { name: string; description: string; inputSchema?: any; + resourceName?: string; + actionName?: string; + path?: string; + method?: string; call: (args: Record, context?: McpToolCallContext) => Promise; }; @@ -21,8 +25,29 @@ export type McpToolCallContext = { headers?: Record; }; +export type McpToolResultPostProcessorContext = { + tool: McpTool; + args: Record; + callContext?: McpToolCallContext; + response?: { + statusCode?: number; + headers?: Record; + body?: any; + }; +}; + +export type McpToolResultPostProcessor = ( + result: any, + context: McpToolResultPostProcessorContext, +) => any | Promise; + export class McpToolsManager { private tools = new Registry(); + private resultPostProcessors = new Map(); + + 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) { + 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()]; } diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/mcp-post-processors.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/mcp-post-processors.test.ts new file mode 100644 index 00000000000..bb39c80e001 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/mcp-post-processors.test.ts @@ -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= to inspect one collection in detail.', + 'Use collections.fields:list with sourceId= 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, + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/mcp-post-processors.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/mcp-post-processors.ts new file mode 100644 index 00000000000..4f82ab025a0 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/mcp-post-processors.ts @@ -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) { + return { + key: item?.key, + name: item?.name, + title: item?.title, + description: item?.description, + }; +} + +function pickFieldSummary(item: Record) { + 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= to inspect one collection in detail.', + 'Use collections.fields:list with sourceId= 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); +} diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts index f378f24df01..59fa3aa0e97 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts @@ -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('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)) { diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/__tests__/mcp-post-processors.test.ts b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/__tests__/mcp-post-processors.test.ts new file mode 100644 index 00000000000..a1bfe0f7942 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/__tests__/mcp-post-processors.test.ts @@ -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, + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/mcp-post-processors.ts b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/mcp-post-processors.ts new file mode 100644 index 00000000000..b9f6c2c87f6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/mcp-post-processors.ts @@ -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) { + const summary: Record = { + displayName: item?.displayName, + key: item?.key, + type: item?.type, + status: item?.status, + }; + + if (Array.isArray(item?.collections)) { + summary.collections = item.collections.map((collection: Record) => { + const nextCollection: Record = pickCollectionSummary({ + ...collection, + dataSourceKey: collection?.dataSourceKey ?? item?.key, + }); + + if (Array.isArray(collection?.fields)) { + nextCollection.fields = collection.fields.map((field: Record) => + pickRemoteFieldSummary(field, { + dataSourceKey: collection?.dataSourceKey ?? item?.key, + collectionName: collection?.name, + }), + ); + } + + return nextCollection; + }); + } + + return summary; +} + +function pickCollectionSummary(item: Record) { + 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) { + return { + name: item?.name, + type: item?.type, + title: item?.uiSchema?.title, + description: item?.description, + dataSourceKey: item?.dataSourceKey, + collectionName: item?.collectionName, + }; +} + +function pickRemoteFieldSummary( + item: Record, + 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; + }, +) { + 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, + }), + ); +} diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/plugin.ts index 0e6539d2da9..b7d548865b8 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/plugin.ts @@ -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(); diff --git a/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/crud-tool.test.ts b/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/crud-tool.test.ts new file mode 100644 index 00000000000..ec50932b0cf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/crud-tool.test.ts @@ -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', + }, + ], + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/mcp-tools.test.ts b/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/mcp-tools.test.ts index e966158d59b..46eccc7975b 100644 --- a/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/mcp-tools.test.ts +++ b/packages/plugins/@nocobase/plugin-mcp-server/src/server/__tests__/mcp-tools.test.ts @@ -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'); + }); }); diff --git a/packages/plugins/@nocobase/plugin-mcp-server/src/server/crud-tool.ts b/packages/plugins/@nocobase/plugin-mcp-server/src/server/crud-tool.ts index 02bf171b77c..363c24d590b 100644 --- a/packages/plugins/@nocobase/plugin-mcp-server/src/server/crud-tool.ts +++ b/packages/plugins/@nocobase/plugin-mcp-server/src/server/crud-tool.ts @@ -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, + }, + }); }, }; } diff --git a/packages/plugins/@nocobase/plugin-mcp-server/src/server/mcp-tools.ts b/packages/plugins/@nocobase/plugin-mcp-server/src/server/mcp-tools.ts index 19a4cbbb059..5c33b371688 100644 --- a/packages/plugins/@nocobase/plugin-mcp-server/src/server/mcp-tools.ts +++ b/packages/plugins/@nocobase/plugin-mcp-server/src/server/mcp-tools.ts @@ -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) { + 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 }; - resourcer: { options?: { prefix?: string } }; - callback: () => any; - }; + app: Application; packagePatterns?: string[]; }): Promise { 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, 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; }); } diff --git a/packages/plugins/@nocobase/plugin-mcp-server/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-mcp-server/src/server/plugin.ts index a56f7620ce8..2d4f1243330 100644 --- a/packages/plugins/@nocobase/plugin-mcp-server/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-mcp-server/src/server/plugin.ts @@ -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;