Commit 9981049b by Finley Ge Committed by GitHub

fix: preserve Agent V2 MCP runtime schemas (#7208)

* fix: preserve Agent V2 MCP runtime schemas

* fix: narrow MCP runtime schema fallback tool
parent d1ab0854
......@@ -44,12 +44,13 @@ import {
} from '@fastgpt/global/core/workflow/utils';
import type { AppSchemaType } from '@fastgpt/global/core/app/type';
import { getAppVersionById } from '../../../../../../app/version/controller';
import { AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import { AppFolderTypeList, AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { SystemToolRepo } from '../../../../../../app/tool/systemTool/systemTool.repo';
import { Output_Template_Error_Message } from '@fastgpt/global/core/workflow/template/output';
import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node';
import { getMCPChildren } from '../../../../../../app/mcp';
type AgentRuntimeNode = RuntimeNodeItemType & {
currentCost?: number;
......@@ -62,8 +63,8 @@ type AgentRuntimeNode = RuntimeNodeItemType & {
* 将 Agent 选择的工具配置转换成 LLM function calling 与 runtime 执行共用的工具描述。
*
* 这里刻意不调用面向前端预览的 getClientToolPreviewNode:Agent runtime 只关心鉴权后的执行
* 节点、toolConfig 和 JSON Schema。App 类工具统一读取当前发布版本;MCP/HTTP 工具集只使用
* 当前版本节点里保存的 toolList,不额外兼容旧版 MCP 数据源
* 节点、toolConfig 和 JSON Schema。App 类工具统一读取当前发布版本;MCP 工具会在运行态补齐
* 旧版子 App 数据或前端预览数据中被裁剪的 schema
*/
export const getAgentRuntimeTools = async ({
tools,
......@@ -205,6 +206,55 @@ export const getAgentRuntimeTools = async ({
};
};
const hasMcpInputSchemaProperties = (schema?: JSONSchemaInputType) => {
return !!schema?.properties && Object.keys(schema.properties).length > 0;
};
const findToolByName = <T extends { name: string }>(toolList: T[], toolName: string) => {
return getToolNameCandidates(toolName)
.map((name) => toolList.find((item) => item.name === name))
.find(Boolean);
};
/**
* Agent 工具面板保存的 MCP toolset 可能来自前端 preview,toolList 仍有工具名但
* inputSchema.properties 已被裁剪。运行态按名称从 MCP app 的 children 中补回完整 schema。
*/
const getMcpToolListWithRuntimeSchema = async ({
app,
toolList
}: {
app?: AppSchemaType;
toolList?: McpToolConfigType[];
}): Promise<McpToolConfigType[]> => {
const currentToolList = toolList ?? [];
if (!app) return currentToolList;
if (!currentToolList.length) {
return getMCPChildren(app);
}
const hasStrippedSchema = currentToolList.some(
(tool) => !hasMcpInputSchemaProperties(tool.inputSchema)
);
if (!hasStrippedSchema) return currentToolList;
const runtimeToolList = await getMCPChildren(app);
if (!runtimeToolList.length) return currentToolList;
return currentToolList.map((tool) => {
if (hasMcpInputSchemaProperties(tool.inputSchema)) return tool;
const runtimeTool = findToolByName(runtimeToolList, tool.name);
if (!runtimeTool || !hasMcpInputSchemaProperties(runtimeTool.inputSchema)) return tool;
return {
...tool,
inputSchema: runtimeTool.inputSchema
};
});
};
/**
* 普通 App 需要根据当前版本节点形态判断运行时类型:
* - pluginInput: 插件工作流
......@@ -284,7 +334,8 @@ export const getAgentRuntimeTools = async ({
/**
* 解析单个 MCP 工具 id: mcp-${appId}/${toolName}。
* 只读取当前版本 toolConfig.mcpToolSet.toolList;不再通过 getMCPChildren 补旧版数据。
* 新版数据从当前版本 toolConfig.mcpToolSet.toolList 读取;旧版 MCP 子工具 schema
* 只保存在子 App 的 toolData 中,需要回退到 getMCPChildren。
*/
const formatMcpToolNode = async ({
app,
......@@ -295,10 +346,12 @@ export const getAgentRuntimeTools = async ({
}): Promise<AgentRuntimeNode> => {
const { toolName } = splitToolsetToolPluginId(pluginId);
const version = await getVersionNodes({ app });
const toolList = version.nodes[0]?.toolConfig?.mcpToolSet?.toolList ?? [];
const tool = getToolNameCandidates(toolName)
.map((name) => toolList.find((item) => item.name === name))
.find(Boolean);
const mcpToolSet = version.nodes[0]?.toolConfig?.mcpToolSet;
const toolList = await getMcpToolListWithRuntimeSchema({
app,
toolList: mcpToolSet?.toolList
});
const tool = findToolByName(toolList, toolName);
if (!tool) return Promise.reject(PluginErrEnum.unExist);
const node = getMCPToolRuntimeNode({
......@@ -540,6 +593,8 @@ export const getAgentRuntimeTools = async ({
const systemToolId = toolNode.toolConfig?.systemToolSet?.toolId;
const mcpToolsetVal = toolNode.toolConfig?.mcpToolSet ?? toolNode.inputs[0]?.value;
const httpToolsetVal = toolNode.toolConfig?.httpToolSet;
const isLegacyMcpToolSet =
authApp?.type === AppTypeEnum.mcpToolSet && !toolNode.toolConfig?.mcpToolSet;
if (systemToolId) {
// System toolset 的子工具由系统工具仓库展开,可能包含内置运行配置。
......@@ -554,12 +609,15 @@ export const getAgentRuntimeTools = async ({
});
return children.map((child) => buildSubApp(child));
} else if (mcpToolsetVal) {
// MCP toolset 已在当前版本节点保存 toolList;展开时不再触发 DB 查询。
const toolList: McpToolConfigType[] = mcpToolsetVal.toolList ?? [];
} else if (mcpToolsetVal || isLegacyMcpToolSet) {
// 新版 MCP toolset 在当前版本节点保存 toolList;旧版数据只有子 App 存 toolData。
const finalToolList = await getMcpToolListWithRuntimeSchema({
app: authApp,
toolList: mcpToolsetVal?.toolList
});
const toolSetId = mcpToolsetVal.toolId || toolNode.pluginId || pluginId;
const children = toolList.map((tool, index) => {
const toolSetId = mcpToolsetVal?.toolId || toolNode.pluginId || pluginId;
const children = finalToolList.map((tool, index) => {
const newToolNode = getMCPToolRuntimeNode({
toolSetId,
toolsetName: toolNode.name,
......
......@@ -4,11 +4,13 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getAgentRuntimeTools } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils';
import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node';
const { authAppByTmbIdMock, getAppVersionByIdMock, getSystemToolDetailMock } = vi.hoisted(() => ({
authAppByTmbIdMock: vi.fn(),
getAppVersionByIdMock: vi.fn(),
getSystemToolDetailMock: vi.fn()
}));
const { authAppByTmbIdMock, getAppVersionByIdMock, getMCPChildrenMock, getSystemToolDetailMock } =
vi.hoisted(() => ({
authAppByTmbIdMock: vi.fn(),
getAppVersionByIdMock: vi.fn(),
getMCPChildrenMock: vi.fn(),
getSystemToolDetailMock: vi.fn()
}));
vi.mock('@fastgpt/service/support/permission/app/auth', () => ({
authAppByTmbId: authAppByTmbIdMock
......@@ -18,6 +20,10 @@ vi.mock('@fastgpt/service/core/app/version/controller', () => ({
getAppVersionById: getAppVersionByIdMock
}));
vi.mock('@fastgpt/service/core/app/mcp', () => ({
getMCPChildren: getMCPChildrenMock
}));
vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({
SystemToolRepo: {
getInstance: vi.fn(() => ({
......@@ -50,6 +56,10 @@ const mcpInputSchema = {
required: ['query']
};
const strippedMcpInputSchema = {
type: 'object'
};
const httpInputSchema = {
type: 'object',
properties: {
......@@ -132,7 +142,7 @@ const createToolsetApp = ({
}: {
id: string;
type: AppTypeEnum.mcpToolSet | AppTypeEnum.httpToolSet;
toolConfig: NodeToolConfigType;
toolConfig?: NodeToolConfigType;
}) => ({
_id: id,
teamId: 'team_1',
......@@ -151,7 +161,7 @@ const createToolsetApp = ({
intro: `${id} intro`,
inputs: [],
outputs: [],
toolConfig
...(toolConfig ? { toolConfig } : {})
}
],
edges: [],
......@@ -161,6 +171,7 @@ const createToolsetApp = ({
describe('getAgentRuntimeTools schema loading', () => {
beforeEach(() => {
vi.clearAllMocks();
getMCPChildrenMock.mockResolvedValue([]);
getSystemToolDetailMock.mockReset();
authAppByTmbIdMock.mockImplementation(async ({ appId }: { appId: string }) => {
......@@ -212,6 +223,26 @@ describe('getAgentRuntimeTools schema loading', () => {
}
}
}),
legacy_mcp_app: createToolsetApp({
id: 'legacy_mcp_app',
type: AppTypeEnum.mcpToolSet
}),
stripped_mcp_app: createToolsetApp({
id: 'stripped_mcp_app',
type: AppTypeEnum.mcpToolSet,
toolConfig: {
mcpToolSet: {
url: 'https://mcp.example.com',
headerSecret: {},
toolList: [
{
...mcpTool,
inputSchema: strippedMcpInputSchema
}
]
}
}
}),
http_app: createToolsetApp({
id: 'http_app',
type: AppTypeEnum.httpToolSet,
......@@ -292,6 +323,103 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
});
it('fills stripped MCP toolset child schema from runtime children', async () => {
getMCPChildrenMock.mockResolvedValue([
{
avatar: 'mcp_app.png',
id: 'mcp-mcp_app/search',
...mcpTool
}
]);
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [
{
id: 'mcp_app',
config: {},
toolConfig: {
mcpToolSet: {
url: '',
toolList: [
{
...mcpTool,
inputSchema: strippedMcpInputSchema
}
]
}
}
}
]
});
expect(getMCPChildrenMock).toHaveBeenCalledWith(appMap.mcp_app);
expect(tools).toHaveLength(1);
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
});
it('fills stripped selected MCP tool schema from runtime children', async () => {
getMCPChildrenMock.mockResolvedValue([
{
avatar: 'stripped_mcp_app.png',
id: 'mcp-stripped_mcp_app/search',
...mcpTool
}
]);
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'mcp-stripped_mcp_app/search', config: {} }]
});
expect(getMCPChildrenMock).toHaveBeenCalledWith(appMap.stripped_mcp_app);
expect(tools).toHaveLength(1);
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
});
it('loads legacy MCP toolset children from stored child tool data', async () => {
getMCPChildrenMock.mockResolvedValue([
{
avatar: 'legacy_mcp_app.png',
id: 'mcp-legacy_mcp_app/search',
...mcpTool
}
]);
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'legacy_mcp_app', config: {} }]
});
expect(getMCPChildrenMock).toHaveBeenCalledWith(appMap.legacy_mcp_app);
expect(tools).toHaveLength(1);
expect(tools[0].requestSchema.function.name).toBe('legacy_mcp_app0');
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-legacy_mcp_app/search');
});
it('loads a selected legacy MCP tool with its input schema', async () => {
getMCPChildrenMock.mockResolvedValue([
{
avatar: 'legacy_mcp_app.png',
id: 'mcp-legacy_mcp_app/search',
...mcpTool
}
]);
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'mcp-legacy_mcp_app/search', config: {} }]
});
expect(getMCPChildrenMock).toHaveBeenCalledWith(appMap.legacy_mcp_app);
expect(tools).toHaveLength(1);
expect(tools[0].id).toBe('legacy_mcp_appsearch');
expect(tools[0].name).toBe('search');
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-legacy_mcp_app/search');
});
it('loads HTTP toolset children with their request schema', async () => {
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment