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 { ...@@ -44,12 +44,13 @@ import {
} from '@fastgpt/global/core/workflow/utils'; } from '@fastgpt/global/core/workflow/utils';
import type { AppSchemaType } from '@fastgpt/global/core/app/type'; import type { AppSchemaType } from '@fastgpt/global/core/app/type';
import { getAppVersionById } from '../../../../../../app/version/controller'; 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 { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { SystemToolRepo } from '../../../../../../app/tool/systemTool/systemTool.repo'; import { SystemToolRepo } from '../../../../../../app/tool/systemTool/systemTool.repo';
import { Output_Template_Error_Message } from '@fastgpt/global/core/workflow/template/output'; import { Output_Template_Error_Message } from '@fastgpt/global/core/workflow/template/output';
import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node'; import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node';
import { getMCPChildren } from '../../../../../../app/mcp';
type AgentRuntimeNode = RuntimeNodeItemType & { type AgentRuntimeNode = RuntimeNodeItemType & {
currentCost?: number; currentCost?: number;
...@@ -62,8 +63,8 @@ type AgentRuntimeNode = RuntimeNodeItemType & { ...@@ -62,8 +63,8 @@ type AgentRuntimeNode = RuntimeNodeItemType & {
* 将 Agent 选择的工具配置转换成 LLM function calling 与 runtime 执行共用的工具描述。 * 将 Agent 选择的工具配置转换成 LLM function calling 与 runtime 执行共用的工具描述。
* *
* 这里刻意不调用面向前端预览的 getClientToolPreviewNode:Agent runtime 只关心鉴权后的执行 * 这里刻意不调用面向前端预览的 getClientToolPreviewNode:Agent runtime 只关心鉴权后的执行
* 节点、toolConfig 和 JSON Schema。App 类工具统一读取当前发布版本;MCP/HTTP 工具集只使用 * 节点、toolConfig 和 JSON Schema。App 类工具统一读取当前发布版本;MCP 工具会在运行态补齐
* 当前版本节点里保存的 toolList,不额外兼容旧版 MCP 数据源 * 旧版子 App 数据或前端预览数据中被裁剪的 schema
*/ */
export const getAgentRuntimeTools = async ({ export const getAgentRuntimeTools = async ({
tools, tools,
...@@ -205,6 +206,55 @@ export const getAgentRuntimeTools = async ({ ...@@ -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 需要根据当前版本节点形态判断运行时类型: * 普通 App 需要根据当前版本节点形态判断运行时类型:
* - pluginInput: 插件工作流 * - pluginInput: 插件工作流
...@@ -284,7 +334,8 @@ export const getAgentRuntimeTools = async ({ ...@@ -284,7 +334,8 @@ export const getAgentRuntimeTools = async ({
/** /**
* 解析单个 MCP 工具 id: mcp-${appId}/${toolName}。 * 解析单个 MCP 工具 id: mcp-${appId}/${toolName}。
* 只读取当前版本 toolConfig.mcpToolSet.toolList;不再通过 getMCPChildren 补旧版数据。 * 新版数据从当前版本 toolConfig.mcpToolSet.toolList 读取;旧版 MCP 子工具 schema
* 只保存在子 App 的 toolData 中,需要回退到 getMCPChildren。
*/ */
const formatMcpToolNode = async ({ const formatMcpToolNode = async ({
app, app,
...@@ -295,10 +346,12 @@ export const getAgentRuntimeTools = async ({ ...@@ -295,10 +346,12 @@ export const getAgentRuntimeTools = async ({
}): Promise<AgentRuntimeNode> => { }): Promise<AgentRuntimeNode> => {
const { toolName } = splitToolsetToolPluginId(pluginId); const { toolName } = splitToolsetToolPluginId(pluginId);
const version = await getVersionNodes({ app }); const version = await getVersionNodes({ app });
const toolList = version.nodes[0]?.toolConfig?.mcpToolSet?.toolList ?? []; const mcpToolSet = version.nodes[0]?.toolConfig?.mcpToolSet;
const tool = getToolNameCandidates(toolName) const toolList = await getMcpToolListWithRuntimeSchema({
.map((name) => toolList.find((item) => item.name === name)) app,
.find(Boolean); toolList: mcpToolSet?.toolList
});
const tool = findToolByName(toolList, toolName);
if (!tool) return Promise.reject(PluginErrEnum.unExist); if (!tool) return Promise.reject(PluginErrEnum.unExist);
const node = getMCPToolRuntimeNode({ const node = getMCPToolRuntimeNode({
...@@ -540,6 +593,8 @@ export const getAgentRuntimeTools = async ({ ...@@ -540,6 +593,8 @@ export const getAgentRuntimeTools = async ({
const systemToolId = toolNode.toolConfig?.systemToolSet?.toolId; const systemToolId = toolNode.toolConfig?.systemToolSet?.toolId;
const mcpToolsetVal = toolNode.toolConfig?.mcpToolSet ?? toolNode.inputs[0]?.value; const mcpToolsetVal = toolNode.toolConfig?.mcpToolSet ?? toolNode.inputs[0]?.value;
const httpToolsetVal = toolNode.toolConfig?.httpToolSet; const httpToolsetVal = toolNode.toolConfig?.httpToolSet;
const isLegacyMcpToolSet =
authApp?.type === AppTypeEnum.mcpToolSet && !toolNode.toolConfig?.mcpToolSet;
if (systemToolId) { if (systemToolId) {
// System toolset 的子工具由系统工具仓库展开,可能包含内置运行配置。 // System toolset 的子工具由系统工具仓库展开,可能包含内置运行配置。
...@@ -554,12 +609,15 @@ export const getAgentRuntimeTools = async ({ ...@@ -554,12 +609,15 @@ export const getAgentRuntimeTools = async ({
}); });
return children.map((child) => buildSubApp(child)); return children.map((child) => buildSubApp(child));
} else if (mcpToolsetVal) { } else if (mcpToolsetVal || isLegacyMcpToolSet) {
// MCP toolset 已在当前版本节点保存 toolList;展开时不再触发 DB 查询。 // 新版 MCP toolset 在当前版本节点保存 toolList;旧版数据只有子 App 存 toolData。
const toolList: McpToolConfigType[] = mcpToolsetVal.toolList ?? []; const finalToolList = await getMcpToolListWithRuntimeSchema({
app: authApp,
toolList: mcpToolsetVal?.toolList
});
const toolSetId = mcpToolsetVal.toolId || toolNode.pluginId || pluginId; const toolSetId = mcpToolsetVal?.toolId || toolNode.pluginId || pluginId;
const children = toolList.map((tool, index) => { const children = finalToolList.map((tool, index) => {
const newToolNode = getMCPToolRuntimeNode({ const newToolNode = getMCPToolRuntimeNode({
toolSetId, toolSetId,
toolsetName: toolNode.name, toolsetName: toolNode.name,
......
...@@ -4,11 +4,13 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; ...@@ -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 { getAgentRuntimeTools } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils';
import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node'; import type { NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node';
const { authAppByTmbIdMock, getAppVersionByIdMock, getSystemToolDetailMock } = vi.hoisted(() => ({ const { authAppByTmbIdMock, getAppVersionByIdMock, getMCPChildrenMock, getSystemToolDetailMock } =
vi.hoisted(() => ({
authAppByTmbIdMock: vi.fn(), authAppByTmbIdMock: vi.fn(),
getAppVersionByIdMock: vi.fn(), getAppVersionByIdMock: vi.fn(),
getMCPChildrenMock: vi.fn(),
getSystemToolDetailMock: vi.fn() getSystemToolDetailMock: vi.fn()
})); }));
vi.mock('@fastgpt/service/support/permission/app/auth', () => ({ vi.mock('@fastgpt/service/support/permission/app/auth', () => ({
authAppByTmbId: authAppByTmbIdMock authAppByTmbId: authAppByTmbIdMock
...@@ -18,6 +20,10 @@ vi.mock('@fastgpt/service/core/app/version/controller', () => ({ ...@@ -18,6 +20,10 @@ vi.mock('@fastgpt/service/core/app/version/controller', () => ({
getAppVersionById: getAppVersionByIdMock getAppVersionById: getAppVersionByIdMock
})); }));
vi.mock('@fastgpt/service/core/app/mcp', () => ({
getMCPChildren: getMCPChildrenMock
}));
vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({ vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({
SystemToolRepo: { SystemToolRepo: {
getInstance: vi.fn(() => ({ getInstance: vi.fn(() => ({
...@@ -50,6 +56,10 @@ const mcpInputSchema = { ...@@ -50,6 +56,10 @@ const mcpInputSchema = {
required: ['query'] required: ['query']
}; };
const strippedMcpInputSchema = {
type: 'object'
};
const httpInputSchema = { const httpInputSchema = {
type: 'object', type: 'object',
properties: { properties: {
...@@ -132,7 +142,7 @@ const createToolsetApp = ({ ...@@ -132,7 +142,7 @@ const createToolsetApp = ({
}: { }: {
id: string; id: string;
type: AppTypeEnum.mcpToolSet | AppTypeEnum.httpToolSet; type: AppTypeEnum.mcpToolSet | AppTypeEnum.httpToolSet;
toolConfig: NodeToolConfigType; toolConfig?: NodeToolConfigType;
}) => ({ }) => ({
_id: id, _id: id,
teamId: 'team_1', teamId: 'team_1',
...@@ -151,7 +161,7 @@ const createToolsetApp = ({ ...@@ -151,7 +161,7 @@ const createToolsetApp = ({
intro: `${id} intro`, intro: `${id} intro`,
inputs: [], inputs: [],
outputs: [], outputs: [],
toolConfig ...(toolConfig ? { toolConfig } : {})
} }
], ],
edges: [], edges: [],
...@@ -161,6 +171,7 @@ const createToolsetApp = ({ ...@@ -161,6 +171,7 @@ const createToolsetApp = ({
describe('getAgentRuntimeTools schema loading', () => { describe('getAgentRuntimeTools schema loading', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
getMCPChildrenMock.mockResolvedValue([]);
getSystemToolDetailMock.mockReset(); getSystemToolDetailMock.mockReset();
authAppByTmbIdMock.mockImplementation(async ({ appId }: { appId: string }) => { authAppByTmbIdMock.mockImplementation(async ({ appId }: { appId: string }) => {
...@@ -212,6 +223,26 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -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({ http_app: createToolsetApp({
id: 'http_app', id: 'http_app',
type: AppTypeEnum.httpToolSet, type: AppTypeEnum.httpToolSet,
...@@ -292,6 +323,103 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -292,6 +323,103 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema); 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 () => { it('loads HTTP toolset children with their request schema', async () => {
const tools = await getAgentRuntimeTools({ const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1', 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