diff --git a/packages/global/core/app/tool/mcpTool/utils.ts b/packages/global/core/app/tool/mcpTool/utils.ts index 38e1490b9a25..fc8d456f36dd 100644 --- a/packages/global/core/app/tool/mcpTool/utils.ts +++ b/packages/global/core/app/tool/mcpTool/utils.ts @@ -46,15 +46,13 @@ export const getMCPToolRuntimeNode = ({ avatar = 'core/app/type/mcpToolsFill', nodeId, toolsetName, - toolSetId, - mcpToolSet + toolSetId }: { nodeId: string; tool: McpToolConfigType; toolSetId: string; toolsetName: string; avatar?: string; - mcpToolSet?: NonNullable; }): RuntimeNodeItemType => { const inputs = jsonSchema2NodeInput({ jsonSchema: tool.inputSchema, schemaType: 'mcp' }).map( (input) => ({ @@ -72,8 +70,7 @@ export const getMCPToolRuntimeNode = ({ toolConfig: { mcpTool: { toolId: `${AppToolSourceEnum.mcp}-${toolSetId}/${tool.name}` // When runtool is used, parentId and toolname will be employed - }, - ...(mcpToolSet ? { mcpToolSet } : {}) + } }, jsonSchema: tool.inputSchema, inputs, diff --git a/packages/global/core/app/tool/toolSet/type.ts b/packages/global/core/app/tool/toolSet/type.ts new file mode 100644 index 000000000000..888e70182693 --- /dev/null +++ b/packages/global/core/app/tool/toolSet/type.ts @@ -0,0 +1,8 @@ +import z from 'zod'; + +/** 工具选择列表和画布使用的纯展示摘要;不依赖执行 Schema,避免编辑器导入循环。 */ +export const ToolSetToolSummarySchema = z.object({ + name: z.string().meta({ description: '工具名称', example: 'search' }), + description: z.string().meta({ description: '工具能力说明', example: 'Search documents' }) +}); +export type ToolSetToolSummaryType = z.infer; diff --git a/packages/global/core/workflow/migration/legacy/workflow.ts b/packages/global/core/workflow/migration/legacy/workflow.ts index df66070dfc74..6f02aa453140 100644 --- a/packages/global/core/workflow/migration/legacy/workflow.ts +++ b/packages/global/core/workflow/migration/legacy/workflow.ts @@ -10,13 +10,13 @@ import { import { migrateSystemConfigToChatConfig } from './systemConfig'; import { FlowNodeInputTypeEnum, FlowNodeTypeEnum } from '../../node/constant'; import { NodeInputKeyEnum, NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../constants'; +import { StoreSecretValueTypeSchema } from '../../../../common/secret/type'; import { getElseIFLabel, getHandleId, isValidReferenceValueFormat, nodeInputIsReference } from '../../utils'; -import type { NodeToolConfigType } from '../../type/node'; // 这些旧插件没有持久化 catchError,但当前运行时需要显式开启错误分支。 const legacyCatchErrorPluginIds = new Set([ @@ -48,6 +48,7 @@ const migrateLegacyCanvasStructure = (workflow: CanonicalWorkflowData): Canonica const legacyMcpToolSet = shouldMigrateLegacyMcpToolSet ? (legacyToolSetValue as Record) : undefined; + const legacyHeaderSecret = StoreSecretValueTypeSchema.safeParse(legacyMcpToolSet?.headerSecret); const inputs = node.inputs.map((input) => { // 文件列表实际是 JSON 数组;旧的单行 input 控件会导致编辑和解析类型不匹配。 if ( @@ -149,13 +150,7 @@ const migrateLegacyCanvasStructure = (workflow: CanonicalWorkflowData): Canonica ...node.toolConfig, mcpToolSet: { url: typeof legacyMcpToolSet.url === 'string' ? legacyMcpToolSet.url : '', - ...(legacyMcpToolSet.headerSecret === undefined - ? {} - : { - headerSecret: legacyMcpToolSet.headerSecret as NonNullable< - NodeToolConfigType['mcpToolSet'] - >['headerSecret'] - }), + ...(legacyHeaderSecret.success ? { headerSecret: legacyHeaderSecret.data } : {}), toolList: Array.isArray(legacyMcpToolSet.toolList) ? legacyMcpToolSet.toolList : [] } } diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 5430506209c8..91a085afe726 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -315,6 +315,11 @@ export const FlowNodeInputItemTypeSchema = InputComponentPropsTypeSchema.extend( }); export type FlowNodeInputItemType = z.infer; +/** MCP/HTTP 引用节点只保留 IO 配置;value/defaultValue 内的业务对象不做递归裁剪。 */ +export const ToolReferenceNodeInputTypeSchema = FlowNodeInputItemTypeSchema.omit({ + customJsonSchema: true +}); + // Workflow node output export const FlowNodeOutputItemTypeSchema = z.object({ id: z.string().meta({ diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index c96a83372440..64d62a50f666 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -2,6 +2,7 @@ import { FlowNodeTypeEnum, NodeColorSchemaEnum } from '../node/constant'; import { FlowNodeInputItemTypeSchema, FlowNodeOutputItemTypeSchema } from './io'; import { HttpToolConfigTypeSchema } from '../../app/tool/httpTool/type'; import { McpToolConfigSchema } from '../../app/tool/mcpTool/type'; +import { ToolSetToolSummarySchema } from '../../app/tool/toolSet/type'; import { ParentIdSchema } from '../../../common/parentFolder/type'; import { InteractiveNodeResponseTypeSchema } from '../template/system/interactive/type'; import { StoreSecretValueTypeSchema } from '../../../common/secret/type'; @@ -10,23 +11,51 @@ import { SourceMemberSchema } from '../../../support/user/type'; import z from 'zod'; import { BoolSchema, NumSchema } from '../../../common/zod'; -export const NodeToolConfigTypeSchema = z.object({ - mcpToolSet: z - .object({ - url: z.string().meta({ - description: 'MCP 服务地址' - }), - headerSecret: StoreSecretValueTypeSchema.nullish().meta({ - description: 'MCP 服务请求头密钥配置' - }), - toolList: z.array(McpToolConfigSchema).meta({ - description: 'MCP 工具集包含的工具列表' - }) - }) - .optional() - .meta({ - description: '节点绑定的 MCP 工具集配置' - }), +export const McpToolSetRuntimeConfigSchema = z.object({ + url: z.string().meta({ + description: 'MCP 服务地址' + }), + headerSecret: StoreSecretValueTypeSchema.nullish().meta({ + description: 'MCP 服务请求头密钥配置' + }), + toolList: z.array(McpToolConfigSchema).meta({ + description: 'MCP 工具集包含的工具列表' + }) +}); +export type McpToolSetRuntimeConfigType = z.infer; + +export const HttpToolSetRuntimeConfigSchema = z.object({ + toolList: z.array(HttpToolConfigTypeSchema).meta({ + description: 'HTTP 工具集包含的工具列表' + }), + baseUrl: z.string().optional().meta({ + description: 'HTTP 工具集请求基础地址' + }), + apiSchemaStr: z.string().optional().meta({ + description: 'HTTP 工具集导入的 OpenAPI Schema 原始内容' + }), + customHeaders: z.string().optional().meta({ + description: 'HTTP 工具集公共请求头 JSON 字符串' + }), + headerSecret: StoreSecretValueTypeSchema.nullish().meta({ + description: 'HTTP 工具集请求头密钥配置' + }) +}); +export type HttpToolSetRuntimeConfigType = z.infer; + +const ToolSetReferenceSchema = z.object({ + toolId: z.string().min(1).meta({ + description: '工具集 ID' + }) +}); + +const ToolSetPreviewReferenceSchema = ToolSetReferenceSchema.extend({ + toolList: z.array(ToolSetToolSummarySchema).optional().meta({ + description: '工具集预览展示摘要,不包含执行 Schema' + }) +}); + +const NodeToolConfigCommonSchema = z.object({ mcpTool: z .object({ toolId: z.string().meta({ @@ -84,28 +113,6 @@ export const NodeToolConfigTypeSchema = z.object({ .meta({ description: '节点绑定的系统工具集配置' }), - httpToolSet: z - .object({ - toolList: z.array(HttpToolConfigTypeSchema).meta({ - description: 'HTTP 工具集包含的工具列表' - }), - baseUrl: z.string().optional().meta({ - description: 'HTTP 工具集请求基础地址' - }), - apiSchemaStr: z.string().optional().meta({ - description: 'HTTP 工具集导入的 OpenAPI Schema 原始内容' - }), - customHeaders: z.string().optional().meta({ - description: 'HTTP 工具集公共请求头 JSON 字符串' - }), - headerSecret: StoreSecretValueTypeSchema.nullish().meta({ - description: 'HTTP 工具集请求头密钥配置' - }) - }) - .optional() - .meta({ - description: '节点绑定的 HTTP 工具集配置' - }), httpTool: z .object({ toolId: z.string().meta({ @@ -117,6 +124,22 @@ export const NodeToolConfigTypeSchema = z.object({ description: '节点绑定的 HTTP 单工具配置' }) }); + +/** 工作流数据兼容当前引用格式与历史完整快照;引用分支优先,避免空 HTTP toolList 被当成完整配置而丢失 ID。 */ +export const NodeToolConfigTypeSchema = NodeToolConfigCommonSchema.extend({ + mcpToolSet: z + .union([ToolSetPreviewReferenceSchema, McpToolSetRuntimeConfigSchema]) + .optional() + .meta({ + description: '兼容历史 MCP 工具集快照或当前工具集引用' + }), + httpToolSet: z + .union([ToolSetPreviewReferenceSchema, HttpToolSetRuntimeConfigSchema]) + .optional() + .meta({ + description: '兼容历史 HTTP 工具集快照或当前工具集引用' + }) +}); export type NodeToolConfigType = z.infer; export const ToolDataSchema = z.object({ diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 981a0689d729..cbabfe43ec9b 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -20,6 +20,7 @@ import { type ReferenceItemValueType } from './type/io'; import type { NodeToolConfigType, StoreNodeItemType } from './type/node'; +import { ToolSetToolSummarySchema } from '../app/tool/toolSet/type'; import type { AppChatConfigType, AppSchemaType, AppWelcomeConfigType } from '../app/type'; import type { VariableItemType } from '../app/variable/type'; import { normalizeAndParseVariableList } from '../app/variable/utils'; @@ -415,7 +416,14 @@ export const toolData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] }) = }; }; -export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] }) => { +/** 工具集预览只携带引用和展示摘要,不携带执行 Schema。 */ +export const toolSetData2FlowNodeIO = ({ + nodes, + toolSetId +}: { + nodes: StoreNodeItemType[]; + toolSetId: string; +}) => { const toolSetNode = nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.toolSet); // 加工 toolConfig, 移除一些无需返回客户端以及无需单独存储到 node 的数据。 @@ -423,31 +431,21 @@ export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] } if (!toolSetNode?.toolConfig) return undefined; if (toolSetNode.toolConfig.httpToolSet) { - const toolList = toolSetNode.toolConfig.httpToolSet.toolList.map((tool) => { - const restTool = { ...tool }; - delete restTool.requestSchema; - delete restTool.inputSchema; - delete restTool.outputSchema; - return restTool; - }); + const toolSet = toolSetNode.toolConfig.httpToolSet; return { - ...toolSetNode.toolConfig, httpToolSet: { - toolList + toolId: 'toolId' in toolSet && toolSet.toolId ? toolSet.toolId : toolSetId, + toolList: ToolSetToolSummarySchema.array().parse(toolSet.toolList ?? []) } }; } if (toolSetNode.toolConfig.mcpToolSet) { - const formatToolList = toolSetNode.toolConfig.mcpToolSet.toolList.map((tool) => { - const restTool = { ...tool }; - delete restTool.inputSchema; - return restTool; - }); + const toolSet = toolSetNode.toolConfig.mcpToolSet; return { - ...toolSetNode.toolConfig, mcpToolSet: { - url: '', - toolList: formatToolList + // 旧工具集 App 用空 toolId 占位;生成新节点时必须使用真实 App ID。 + toolId: 'toolId' in toolSet && toolSet.toolId ? toolSet.toolId : toolSetId, + toolList: ToolSetToolSummarySchema.array().parse(toolSet.toolList ?? []) } }; } diff --git a/packages/global/openapi/core/app/tool/api.ts b/packages/global/openapi/core/app/tool/api.ts index f9a24bbf580c..f1f2c256ad89 100644 --- a/packages/global/openapi/core/app/tool/api.ts +++ b/packages/global/openapi/core/app/tool/api.ts @@ -8,6 +8,44 @@ import { OpenAPIFlowNodeOutputItemTypeSchema } from '../../workflow/node'; import { BoolSchema } from '../../../../common/zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; +import { AppTypeEnum } from '../../../../core/app/constants'; +import { ToolSetToolSummarySchema } from '../../../../core/app/tool/toolSet/type'; + +/* ============================================================================ + * API: 获取工具集子工具摘要 + * Route: GET /api/core/app/tool/getToolSetChildren + * Method: GET + * Description: 工具选择列表使用的精简接口,普通目录返回空工具列表及目录类型,不返回执行配置 + * Tags: ['工具节点预览', 'HTTP 工具管理', 'MCP 工具管理', 'Read'] + * ============================================================================ */ +export const GetToolSetChildrenQuerySchema = z.object({ + appId: ObjectIdSchema.meta({ + description: '待展开的工具集或目录 ID', + example: '507f1f77bcf86cd799439011' + }), + searchKey: z.string().optional().meta({ description: '按工具名搜索的关键字', example: 'search' }) +}); +export type GetToolSetChildrenQueryType = z.infer; + +export const GetToolSetChildrenResponseSchema = z.object({ + type: z.enum(AppTypeEnum).meta({ + description: '资源类型;非 MCP/HTTP 工具集时由客户端继续加载普通目录', + example: AppTypeEnum.httpToolSet + }), + tools: z + .array( + ToolSetToolSummarySchema.extend({ + id: z.string().meta({ + description: '可供预览和执行的子工具 ID', + example: 'http-507f1f77bcf86cd799439011/search' + }), + avatar: z.string().meta({ description: '工具图标', example: 'core/app/type/httpToolsFill' }) + }) + ) + .meta({ description: '只含 ID、名称、说明和图标的子工具摘要' }) +}); +export type GetToolSetChildrenResponseType = z.infer; const ToolNodeTemplateListItemSchema = NodeTemplateListItemTypeSchema.extend({ toolDescription: z.string().optional().meta({ diff --git a/packages/global/openapi/core/app/tool/index.ts b/packages/global/openapi/core/app/tool/index.ts index 125dba19b0fb..7bd28cff9860 100644 --- a/packages/global/openapi/core/app/tool/index.ts +++ b/packages/global/openapi/core/app/tool/index.ts @@ -1,6 +1,8 @@ import type { OpenAPIPath } from '../../../type'; import { DevApiTagsMap } from '../../../tag'; import { + GetToolSetChildrenQuerySchema, + GetToolSetChildrenResponseSchema, GetPreviewNodeQueryOpenAPISchema, GetPreviewNodeResponseSchema, GetSystemToolTemplatesBodySchema, @@ -10,6 +12,21 @@ import { } from './api'; export const ToolPath: OpenAPIPath = { + '/core/app/tool/getToolSetChildren': { + get: { + summary: '获取工具集子工具摘要', + description: + '供工具选择列表展开 MCP/HTTP 工具集使用,仅返回展示字段;普通目录返回目录类型和空列表。', + tags: [DevApiTagsMap.toolPreview, DevApiTagsMap.httpTools, DevApiTagsMap.mcpTools], + requestParams: { query: GetToolSetChildrenQuerySchema }, + responses: { + 200: { + description: '成功获取工具摘要', + content: { 'application/json': { schema: GetToolSetChildrenResponseSchema } } + } + } + } + }, '/core/app/tool/getSystemToolTemplates': { post: { summary: '获取系统工具模板列表', diff --git a/packages/global/test/core/workflow/utils.test.ts b/packages/global/test/core/workflow/utils.test.ts index 8ea679a1f1a8..5eff08b8ca8b 100644 --- a/packages/global/test/core/workflow/utils.test.ts +++ b/packages/global/test/core/workflow/utils.test.ts @@ -1041,6 +1041,30 @@ describe('toolData2FlowNodeIO', () => { }); describe('toolSetData2FlowNodeIO', () => { + it.each(['mcpToolSet', 'httpToolSet'] as const)( + 'uses the App id for an empty legacy %s id and preserves explicit ids', + (key) => { + const node = { + nodeId: 'set', + name: 'Legacy', + flowNodeType: FlowNodeTypeEnum.toolSet, + inputs: [], + outputs: [], + toolConfig: { [key]: { toolId: '', url: 'https://example.com', toolList: [] } } + }; + const original = structuredClone(node); + const preview = toolSetData2FlowNodeIO({ nodes: [node] as any, toolSetId: 'actual-app-id' }); + expect(preview.toolConfig?.[key]).toEqual({ toolId: 'actual-app-id', toolList: [] }); + expect(node).toEqual(original); + node.toolConfig[key].toolId = 'explicit-app-id'; + expect( + toolSetData2FlowNodeIO({ nodes: [node] as any, toolSetId: 'actual-app-id' }).toolConfig?.[ + key + ] + ).toEqual({ toolId: 'explicit-app-id', toolList: [] }); + } + ); + it('should return empty arrays when no toolSet node exists', () => { const nodes: StoreNodeItemType[] = [ { @@ -1051,7 +1075,7 @@ describe('toolSetData2FlowNodeIO', () => { outputs: [] } ]; - const result = toolSetData2FlowNodeIO({ nodes }); + const result = toolSetData2FlowNodeIO({ nodes, toolSetId: 'test' }); expect(result.inputs).toEqual([]); expect(result.outputs).toEqual([]); expect(result.toolConfig).toBeUndefined(); @@ -1077,7 +1101,7 @@ describe('toolSetData2FlowNodeIO', () => { toolConfig: { mcpToolSet: { toolId: 'test', url: 'http://test', toolList: [] } } } ]; - const result = toolSetData2FlowNodeIO({ nodes }); + const result = toolSetData2FlowNodeIO({ nodes, toolSetId: 'test' }); expect(result.inputs).toEqual(toolSetInputs); expect(result.showSourceHandle).toBe(false); expect(result.showTargetHandle).toBe(false); diff --git a/packages/service/core/app/http.ts b/packages/service/core/app/http.ts index fc983f6937ed..c05d5d4cbc42 100644 --- a/packages/service/core/app/http.ts +++ b/packages/service/core/app/http.ts @@ -3,7 +3,11 @@ import { getSecretValue } from '../../common/secret/utils'; import { axios } from '../../common/api/axios'; import { getErrText } from '@fastgpt/global/common/error/utils'; import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils'; -import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { + HttpToolConfigTypeSchema, + type HttpToolConfigType +} from '@fastgpt/global/core/app/tool/httpTool/type'; import { contentTypeMap, ContentTypes } from '@fastgpt/global/core/workflow/constants'; import { isInternalAddress, PRIVATE_URL_TEXT } from '../../common/system/utils'; import type { AppSchemaType } from '@fastgpt/global/core/app/type'; @@ -188,10 +192,18 @@ export const runHTTPTool = async ({ } }; +/** Read the current HTTP tool list from a toolset app. */ export const getHTTPToolList = async (app: AppSchemaType) => { + if (app.type !== AppTypeEnum.httpToolSet) return []; + const modules = decodeHttpToolSetNodesFromStorage(app.modules); + const toolSet = modules[0]?.toolConfig?.httpToolSet; + const toolList = HttpToolConfigTypeSchema.array().safeParse( + toolSet && 'toolList' in toolSet ? toolSet.toolList : undefined + ).data; + return ( - modules[0].toolConfig?.httpToolSet?.toolList.map((item) => ({ + toolList?.map((item) => ({ ...item, id: `${AppToolSourceEnum.http}-${String(app._id)}/${item.name}`, avatar: app.avatar diff --git a/packages/service/core/app/mcp.ts b/packages/service/core/app/mcp.ts index b56e0d079548..017583cfeb60 100644 --- a/packages/service/core/app/mcp.ts +++ b/packages/service/core/app/mcp.ts @@ -6,7 +6,13 @@ import { } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { AppSchemaType } from '@fastgpt/global/core/app/type'; import { type McpToolConfigType } from '@fastgpt/global/core/app/tool/mcpTool/type'; +import { + SecretValueTypeSchema, + StoreSecretValueTypeSchema, + type StoreSecretValueType +} from '@fastgpt/global/common/secret/type'; import { retryFn } from '@fastgpt/global/common/system/utils'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants'; import { MongoApp } from './schema'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; @@ -15,9 +21,17 @@ import $RefParser from '@apidevtools/json-schema-ref-parser'; import { getLogger, LogCategories } from '../../common/logger'; import { isInternalAddress, PRIVATE_URL_TEXT } from '../../common/system/utils'; import { decodeMcpToolSetNodesFromStorage } from './jsonSchemaStorage'; +import { McpToolSetRuntimeConfigSchema } from '@fastgpt/global/core/workflow/type/node'; const logger = getLogger(LogCategories.MODULE.APP.MCP_TOOLS); +type McpChildToolType = McpToolConfigType & { + id: string; + avatar: string; + url?: string; + headerSecret?: StoreSecretValueType; +}; + const MCP_SAFE_FETCH_MAX_REDIRECTS = 5; const MCP_REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); const MCP_SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']); @@ -408,14 +422,17 @@ export class MCPClient { } } -export const getMCPChildren = async (app: AppSchemaType) => { - const modules = decodeMcpToolSetNodesFromStorage(app.modules); - const isNewMcp = !!modules[0].toolConfig?.mcpToolSet; +/** Read the current or legacy MCP child tools from a toolset app. */ +export const getMCPChildren = async (app: AppSchemaType): Promise => { + if (app.type !== AppTypeEnum.mcpToolSet) return []; + const id = String(app._id); + const modules = decodeMcpToolSetNodesFromStorage(app.modules); + const toolSet = McpToolSetRuntimeConfigSchema.safeParse(modules[0]?.toolConfig?.mcpToolSet).data; - if (isNewMcp) { + if (toolSet) { return ( - modules[0].toolConfig?.mcpToolSet?.toolList.map((item) => ({ + toolSet.toolList.map((item) => ({ ...item, id: `${AppToolSourceEnum.mcp}-${id}/${item.name}`, avatar: app.avatar @@ -431,11 +448,20 @@ export const getMCPChildren = async (app: AppSchemaType) => { return children.map((item) => { const node = item.modules[0]; const toolData: McpToolDataType = node.inputs[0].value; + const { headerSecret, ...toolConfig } = toolData; + const normalizedHeaders = (() => { + if (!headerSecret) return undefined; + // 旧版本同时存在命名请求头映射和单个密钥;映射优先,避免重复包装 Authorization。 + const headerMap = StoreSecretValueTypeSchema.safeParse(headerSecret); + if (headerMap.success) return headerMap.data; + return { Authorization: SecretValueTypeSchema.parse(headerSecret) }; + })(); return { avatar: app.avatar, id: `${AppToolSourceEnum.mcp}-${id}/${item.name}`, - ...toolData + ...toolConfig, + ...(normalizedHeaders ? { headerSecret: normalizedHeaders } : {}) }; }); } diff --git a/packages/service/core/app/tool/utils/client.ts b/packages/service/core/app/tool/utils/client.ts index fa9789752fb7..a6bfc6365d08 100644 --- a/packages/service/core/app/tool/utils/client.ts +++ b/packages/service/core/app/tool/utils/client.ts @@ -33,9 +33,11 @@ import type { FlowNodeInputItemType, FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; -import type { - FlowNodeTemplateType, - NodeToolConfigType +import { ToolReferenceNodeInputTypeSchema } from '@fastgpt/global/core/workflow/type/io'; +import { + FlowNodeTemplateTypeSchema, + type FlowNodeTemplateType, + type NodeToolConfigType } from '@fastgpt/global/core/workflow/type/node'; import { pluginData2FlowNodeIO, @@ -45,7 +47,9 @@ import { projectExternalVariableInput } from '@fastgpt/global/core/workflow/utils'; import { Types } from 'mongoose'; +import { getHTTPToolList } from '../../http'; import { getMCPChildren } from '../../mcp'; +import { decodeToolSetNodesFromStorage } from '../../jsonSchemaStorage'; import { MongoApp } from '../../schema'; import { getAppVersionById, checkIsLatestVersion } from '../../version/controller'; import { SystemToolRepo } from '../systemTool/systemTool.repo'; @@ -103,35 +107,8 @@ type AppToolType = WorkflowTemplateType & { isLatestVersion?: boolean; // Auto computed }; -const omitRuntimeJsonSchemaField = (value: T): T => { - if (Array.isArray(value)) { - return value.map((item) => omitRuntimeJsonSchemaField(item)) as T; - } - - if (!value || typeof value !== 'object') return value; - - const rest = { ...(value as Record) }; - delete rest.jsonSchema; - - return Object.fromEntries( - Object.entries(rest).map(([key, item]) => [key, omitRuntimeJsonSchemaField(item)]) - ) as T; -}; - -const omitClientPreviewSchemaFields = >(value: T): T => { - const rest = { ...value }; - delete rest.inputSchema; - delete rest.outputSchema; - delete rest.secretSchema; - - return omitRuntimeJsonSchemaField(rest) as T; -}; - /** * 构建返回给客户端的系统工具预览节点。 - * - * 该节点只用于前端 UI 展示、工具选择和插入画布;服务端会用 JSON Schema - * 转成节点 IO,但响应中不携带原始 schema,避免把运行时契约混进客户端预览数据。 */ export async function getClientSystemToolPreviewNode({ pluginId, @@ -267,8 +244,8 @@ export async function getClientSystemToolPreviewNode({ /** * 构建返回给客户端的工具预览节点。 * - * 该结果只用于前端 UI 展示、工具选择和插入画布。运行时 JSON Schema 只在服务端 - * 内部用于转换节点 IO,返回前会被裁剪,避免客户端依赖执行阶段的 schema contract。 + * 该结果只用于前端 UI 展示、工具选择和插入画布。运行时 JSON Schema + * 在服务端内部用于转换节点 IO,不返回给客户端预览数据。 */ export async function getClientToolPreviewNode({ appId, @@ -307,14 +284,24 @@ export async function getClientToolPreviewNode({ if (!item) return Promise.reject(PluginErrEnum.unExist); if (AppFolderTypeList.includes(item.type)) return Promise.reject(PluginErrEnum.unExist); - const version = await getAppVersionById({ - appId: pluginId, - versionId: versionId || undefined, - app: item - }); + const isToolSetApp = + item.type === AppTypeEnum.mcpToolSet || item.type === AppTypeEnum.httpToolSet; + const version = isToolSetApp + ? { + versionId: undefined, + versionName: undefined, + nodes: [...decodeToolSetNodesFromStorage(item.modules)], + edges: item.edges, + chatConfig: item.chatConfig + } + : await getAppVersionById({ + appId: pluginId, + versionId: versionId || undefined, + app: item + }); const isLatest = - version.versionId && Types.ObjectId.isValid(version.versionId) + !isToolSetApp && version.versionId && Types.ObjectId.isValid(version.versionId) ? await checkIsLatestVersion({ appId: pluginId, versionId: version.versionId @@ -322,18 +309,29 @@ export async function getClientToolPreviewNode({ : true; // Adapt - if (item.type === AppTypeEnum.mcpToolSet && !version.nodes[0].toolConfig) { + if (item.type === AppTypeEnum.mcpToolSet && !version.nodes[0]?.toolConfig?.mcpToolSet) { const children = await getMCPChildren(item); - version.nodes[0].toolConfig = { - mcpToolSet: { - toolList: children, - url: '', - headerSecret: {} + version.nodes[0] = { + ...version.nodes[0], + // 仅在生成新预览时去掉已知旧配置槽,保留普通 IO;不能回写或截断存量节点输入。 + inputs: (version.nodes[0]?.inputs ?? []).filter( + (input) => + input.key !== NodeInputKeyEnum.toolSetData || + !input.renderTypeList.includes(FlowNodeInputTypeEnum.hidden) + ), + toolConfig: { + ...version.nodes[0]?.toolConfig, + mcpToolSet: { + toolList: children, + url: '', + headerSecret: {} + } } }; } - const shouldReturnVersion = versionId ? true : versionId === undefined && getLatestVersion; + const shouldReturnVersion = + !isToolSetApp && (versionId ? true : versionId === undefined && getLatestVersion); return { id: String(item._id), @@ -366,23 +364,12 @@ export async function getClientToolPreviewNode({ const item = await MongoApp.findById(parentId).lean(); if (!item) return Promise.reject(PluginErrEnum.unExist); - const version = await getAppVersionById({ - appId: parentId, - versionId: versionId || undefined, - app: item - }); - const toolConfig = version.nodes[0].toolConfig?.mcpToolSet; const tool = await (async () => { const matchTool = (tools: T[]) => getToolNameCandidates(toolName) .map((name) => tools.find((item) => item.name === name)) .find(Boolean); - if (toolConfig?.toolList) { - // new mcp toolset - return matchTool(toolConfig.toolList); - } - // old mcp toolset return matchTool(await getMCPChildren(item)); })(); if (!tool) return Promise.reject(PluginErrEnum.unExist); @@ -417,20 +404,10 @@ export async function getClientToolPreviewNode({ const item = await MongoApp.findById(parentId).lean(); if (!item) return Promise.reject(PluginErrEnum.unExist); - const version = await getAppVersionById({ - appId: parentId, - versionId: versionId || undefined, - app: item - }); - const toolConfig = version.nodes[0].toolConfig?.httpToolSet; - const tool = await (async () => { - if (toolConfig?.toolList) { - return getToolNameCandidates(toolName) - .map((name) => toolConfig.toolList.find((item) => item.name === name)) - .find(Boolean); - } - return undefined; - })(); + const toolList = await getHTTPToolList(item); + const tool = getToolNameCandidates(toolName) + .map((name) => toolList.find((item) => item.name === name)) + .find(Boolean); if (!tool) return Promise.reject(PluginErrEnum.unExist); return { avatar: item.avatar, @@ -493,7 +470,7 @@ export async function getClientToolPreviewNode({ // mcp tools return { flowNodeType: FlowNodeTypeEnum.toolSet, - nodeIOConfig: toolSetData2FlowNodeIO({ nodes: app.workflow.nodes }) + nodeIOConfig: toolSetData2FlowNodeIO({ nodes: app.workflow.nodes, toolSetId: app.id }) }; } @@ -557,5 +534,19 @@ export async function getClientToolPreviewNode({ }; })(); - return omitClientPreviewSchemaFields(data); + const isMcpOrHttpTool = + idSource === AppToolSourceEnum.mcp || + idSource === AppToolSourceEnum.http || + !!data.toolConfig?.mcpTool || + !!data.toolConfig?.httpTool || + !!data.toolConfig?.mcpToolSet || + !!data.toolConfig?.httpToolSet; + + // 普通工作流和系统工具的自定义 IO 定义仍有效,不套用外部工具的裁剪契约。 + if (!isMcpOrHttpTool) return data; + + return FlowNodeTemplateTypeSchema.parse({ + ...data, + inputs: ToolReferenceNodeInputTypeSchema.array().parse(data.inputs) + }); } diff --git a/packages/service/core/app/utils.ts b/packages/service/core/app/utils.ts index 56db20d24e8a..acb129ce18af 100644 --- a/packages/service/core/app/utils.ts +++ b/packages/service/core/app/utils.ts @@ -215,9 +215,19 @@ export async function rewriteAppWorkflowToDetail({ } // Tool node - if (node.pluginId) { + const toolId = + node.pluginId ?? + node.toolConfig?.mcpTool?.toolId ?? + node.toolConfig?.httpTool?.toolId ?? + (node.toolConfig?.mcpToolSet && 'toolId' in node.toolConfig.mcpToolSet + ? node.toolConfig.mcpToolSet.toolId + : undefined) ?? + (node.toolConfig?.httpToolSet && 'toolId' in node.toolConfig.httpToolSet + ? node.toolConfig.httpToolSet.toolId + : undefined); + if (toolId) { const result = await loadToolNode({ - id: node.pluginId, + id: toolId, versionId: node.version ?? '', source: node.source ?? @@ -282,6 +292,7 @@ export async function rewriteAppWorkflowToDetail({ ) { node.inputs = node.inputs.map(projectExternalVariableInput); } + // Agent, parse subapp if (node.flowNodeType === FlowNodeTypeEnum.agent) { // Tool load @@ -341,7 +352,11 @@ export async function rewriteAppWorkflowToDetail({ return { ...data, source: tool.source ?? data.source, - toolConfig: tool.toolConfig ?? data.toolConfig, + toolConfig: + (tool.toolConfig?.mcpToolSet && 'toolId' in tool.toolConfig.mcpToolSet) || + (tool.toolConfig?.httpToolSet && 'toolId' in tool.toolConfig.httpToolSet) + ? data.toolConfig + : (tool.toolConfig ?? data.toolConfig), inputs: mergedInputs }; } else { diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/tool/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/tool/index.ts index b16909d5c4f3..f71d97990e9b 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/tool/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/tool/index.ts @@ -15,9 +15,18 @@ import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { pushTrack } from '../../../../../../../common/middle/tracks/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; -import { getAppVersionById } from '../../../../../../app/version/controller'; -import { assertMCPUrlNotInternal, MCPClient } from '../../../../../../app/mcp'; -import { runHTTPTool } from '../../../../../../app/http'; +import { assertToolRuntimeParams } from '@fastgpt/global/core/app/tool/runtime'; +import { getHTTPToolRuntimeSchemas } from '@fastgpt/global/core/app/tool/httpTool/utils'; +import { assertMCPUrlNotInternal, getMCPChildren, MCPClient } from '../../../../../../app/mcp'; +import { getHTTPToolList, runHTTPTool } from '../../../../../../app/http'; +import { + decodeHttpToolSetNodesFromStorage, + decodeMcpToolSetNodesFromStorage +} from '../../../../../../app/jsonSchemaStorage'; +import { + HttpToolSetRuntimeConfigSchema, + McpToolSetRuntimeConfigSchema +} from '@fastgpt/global/core/workflow/type/node'; import { isPluginAnswerType, parseToolId } from '../../../../child/runTool'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils'; @@ -125,13 +134,14 @@ export const dispatchTool = async ({ * Agent 工具调用也会按持久化 toolId 解析 HTTP/MCP 父工具集。 * 这里必须使用当前运行工作流的 tmbId 做运行时授权,防止绕过保存阶段的脏引用被模型调用执行。 */ - const authRuntimeToolset = async (parentId: string) => { - await authAppByTmbId({ - tmbId: runningAppInfo.tmbId, - appId: parentId, - per: ReadPermissionVal - }); - }; + const authRuntimeToolset = async (parentId: string) => + ( + await authAppByTmbId({ + tmbId: runningAppInfo.tmbId, + appId: parentId, + per: ReadPermissionVal + }) + ).app; if (toolConfig?.systemTool?.toolId) { const toolSource = getSystemToolSource(); @@ -246,19 +256,25 @@ export const dispatchTool = async ({ if (!parentId || !toolName) { return Promise.reject(`Invalid MCP tool id: ${toolConfig.mcpTool.toolId}`); } - await authRuntimeToolset(parentId); - - const mcpToolSet = toolConfig.mcpToolSet; - if (!mcpToolSet) { - return Promise.reject(`MCP tool set is missing from runtime node`); + const app = await authRuntimeToolset(parentId); + const mcpToolSet = McpToolSetRuntimeConfigSchema.safeParse( + decodeMcpToolSetNodesFromStorage(app.modules)[0]?.toolConfig?.mcpToolSet + ).data; + const mcpToolList = await getMCPChildren(app); + if (!mcpToolSet && !mcpToolList.length) { + return Promise.reject(`MCP tool set is missing`); } const mcpTool = getToolNameCandidates(toolName) - .map((name) => mcpToolSet.toolList.find((tool) => tool.name === name)) + .map((name) => mcpToolList.find((tool) => tool.name === name)) .find(Boolean); if (!mcpTool) { return Promise.reject(`MCP tool not found: ${toolConfig.mcpTool.toolId}`); } - const { headerSecret, url } = mcpToolSet; + const url = mcpToolSet?.url ?? mcpTool.url; + const headerSecret = mcpToolSet?.headerSecret ?? mcpTool.headerSecret; + if (!url) { + return Promise.reject('MCP tool set URL is missing'); + } await assertMCPUrlNotInternal(url); @@ -269,6 +285,8 @@ export const dispatchTool = async ({ }) }); + // 工具定义可能在 Agent 生成参数后更新;执行前必须使用本次读取的完整约束校验。 + assertToolRuntimeParams({ jsonSchema: mcpTool.inputSchema, params }); const result = await mcpClient.toolCall({ toolName, params @@ -284,18 +302,16 @@ export const dispatchTool = async ({ if (!parentId || !toolName) { return Promise.reject(`Invalid HTTP tool id: ${toolConfig.httpTool.toolId}`); } - await authRuntimeToolset(parentId); - - const toolset = await getAppVersionById({ - appId: parentId, - versionId: version - }); - const toolSetData = toolset.nodes[0].toolConfig?.httpToolSet; - if (!toolSetData || typeof toolSetData !== 'object') { + const app = await authRuntimeToolset(parentId); + const toolSetData = HttpToolSetRuntimeConfigSchema.safeParse( + decodeHttpToolSetNodesFromStorage(app.modules)[0]?.toolConfig?.httpToolSet + ).data; + const toolList = await getHTTPToolList(app); + if (!toolSetData && !toolList.length) { return Promise.reject(`HTTP tool set not found: ${toolConfig.httpTool.toolId}`); } - const { headerSecret, baseUrl, toolList, customHeaders } = toolSetData; + const { headerSecret, baseUrl, customHeaders } = toolSetData ?? {}; const httpTool = getToolNameCandidates(toolName) .map((name) => toolList?.find((tool) => tool.name === name)) @@ -304,6 +320,9 @@ export const dispatchTool = async ({ return Promise.reject(`HTTP tool ${toolName} not found`); } + // 仅规范化本次回源的定义,不使用节点快照;兼容旧标量或缺失的 requestSchema。 + const { requestSchema } = getHTTPToolRuntimeSchemas(httpTool); + assertToolRuntimeParams({ jsonSchema: requestSchema, params }); const { data, errorMsg } = await runHTTPTool({ baseUrl: baseUrl || '', toolPath: httpTool.path, diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/tool/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/tool/utils.ts index d7b574974f14..d5016ba7e674 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/tool/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/tool/utils.ts @@ -29,7 +29,6 @@ import { type JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import type { McpToolConfigType } from '@fastgpt/global/core/app/tool/mcpTool/type'; import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type'; import type { SubAppInitType } from '../type'; import { @@ -60,6 +59,8 @@ import { SystemToolRepo } from '../../../../../../app/tool/systemTool/systemTool 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'; +import { getHTTPToolList } from '../../../../../../app/http'; +import { decodeToolSetNodesFromStorage } from '../../../../../../app/jsonSchemaStorage'; import { getTmbInfoByTmbId } from '../../../../../../../support/user/team/controller'; import { assertTeamPluginSourceAccess, @@ -256,6 +257,16 @@ export const getAgentRuntimeTools = async ({ app: AppSchemaType; versionId?: string; }) => { + if (app.type === AppTypeEnum.mcpToolSet || app.type === AppTypeEnum.httpToolSet) { + return { + versionId: undefined, + versionName: undefined, + nodes: decodeToolSetNodesFromStorage(app.modules), + edges: app.edges, + chatConfig: app.chatConfig + }; + } + const version = await getAppVersionById({ appId: String(app._id), versionId, @@ -268,88 +279,12 @@ export const getAgentRuntimeTools = async ({ }; }; - const hasMcpInputSchemaProperties = (schema?: JSONSchemaInputType) => { - return !!schema?.properties && Object.keys(schema.properties).length > 0; - }; - const findToolByName = (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 => { - 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 - }; - }); - }; - - type RuntimeMcpToolSet = NonNullable< - NonNullable['mcpToolSet'] - >; - type RuntimeMcpTool = McpToolConfigType & { - url?: string; - headerSecret?: RuntimeMcpToolSet['headerSecret']; - id?: string; - avatar?: string; - }; - - /** 将 Agent MCP 工具资源投影为执行阶段使用的 canonical ToolSet。 */ - const buildMcpRuntimeToolSet = ({ - mcpToolSet, - toolList, - selectedTool - }: { - mcpToolSet?: RuntimeMcpToolSet; - toolList: RuntimeMcpTool[]; - selectedTool?: RuntimeMcpTool; - }): RuntimeMcpToolSet | undefined => { - const url = selectedTool?.url ?? mcpToolSet?.url; - if (!url) return undefined; - - const headerSecret = selectedTool?.headerSecret ?? mcpToolSet?.headerSecret; - return { - url, - ...(headerSecret ? { headerSecret } : {}), - toolList: toolList.map( - ({ url: _url, headerSecret: _headerSecret, id: _id, avatar: _avatar, ...tool }) => tool - ) - }; - }; - /** * 普通 App 需要根据当前版本节点形态判断运行时类型: * - pluginInput: 插件工作流 @@ -431,25 +366,18 @@ export const getAgentRuntimeTools = async ({ /** * 解析单个 MCP 工具 id: mcp-${appId}/${toolName}。 - * 新版数据从当前版本 toolConfig.mcpToolSet.toolList 读取;旧版 MCP 子工具 schema - * 只保存在子 App 的 toolData 中,需要回退到 getMCPChildren。 + * 运行时始终从 MCP 工具集应用读取完整工具定义;旧版子 App 数据由 getMCPChildren + * 内部兼容处理。 */ const formatMcpToolNode = async ({ app, - pluginId, - versionId + pluginId }: { app: AppSchemaType; pluginId: string; - versionId?: string; }): Promise => { const { toolName } = splitToolsetToolPluginId(pluginId); - const version = await getVersionNodes({ app, versionId }); - const mcpToolSet = version.nodes[0]?.toolConfig?.mcpToolSet; - const toolList = await getMcpToolListWithRuntimeSchema({ - app, - toolList: mcpToolSet?.toolList - }); + const toolList = await getMCPChildren(app); const tool = findToolByName(toolList, toolName); if (!tool) return Promise.reject(PluginErrEnum.unExist); @@ -458,18 +386,13 @@ export const getAgentRuntimeTools = async ({ toolSetId: String(app._id), toolsetName: app.name, avatar: app.avatar, - tool, - mcpToolSet: buildMcpRuntimeToolSet({ - mcpToolSet, - toolList: toolList as RuntimeMcpTool[], - selectedTool: tool as RuntimeMcpTool - }) + tool }); // 单独选择子工具时,模型侧展示子工具名即可,不需要带 toolset 前缀。 return { ...node, - version: versionId ?? '', + version: '', name: tool.name, intro: tool.description }; @@ -481,16 +404,13 @@ export const getAgentRuntimeTools = async ({ */ const formatHttpToolNode = async ({ app, - pluginId, - versionId + pluginId }: { app: AppSchemaType; pluginId: string; - versionId?: string; }): Promise => { const { toolName } = splitToolsetToolPluginId(pluginId); - const version = await getVersionNodes({ app, versionId }); - const toolList = version.nodes[0]?.toolConfig?.httpToolSet?.toolList ?? []; + const toolList = await getHTTPToolList(app); const tool = getToolNameCandidates(toolName) .map((name) => toolList.find((item) => item.name === name)) .find(Boolean); @@ -507,7 +427,7 @@ export const getAgentRuntimeTools = async ({ // 单独选择子工具时,模型侧展示子工具名即可,不需要带 toolset 前缀。 return { ...node, - version: versionId ?? '', + version: '', name: tool.name, intro: tool.description }; @@ -540,10 +460,10 @@ export const getAgentRuntimeTools = async ({ } if (!app) return Promise.reject(PluginErrEnum.unExist); if (idSource === AppToolSourceEnum.mcp) { - return formatMcpToolNode({ app, pluginId, versionId }); + return formatMcpToolNode({ app, pluginId }); } if (idSource === AppToolSourceEnum.http) { - return formatHttpToolNode({ app, pluginId, versionId }); + return formatHttpToolNode({ app, pluginId }); } return formatPersonalAppNode({ app, versionId }); }; @@ -579,6 +499,25 @@ export const getAgentRuntimeTools = async ({ }); }; + /** Resolve a referenced MCP/HTTP toolset and enforce the Agent member's read permission. */ + const getRuntimeToolSetApp = async ({ + app, + toolSetId + }: { + app?: AppSchemaType; + toolSetId?: string; + }) => { + if (!app || !toolSetId || String(app._id) === toolSetId) return app; + + return ( + await authAppByTmbId({ + tmbId, + appId: toolSetId, + per: ReadPermissionVal + }) + ).app; + }; + return Promise.all( tools.map>(async (tool) => { try { @@ -605,7 +544,11 @@ export const getAgentRuntimeTools = async ({ pluginId, app: authResult?.app, toolId: tool.id, - versionId: tool.version + versionId: + authResult?.app?.type === AppTypeEnum.mcpToolSet || + authResult?.app?.type === AppTypeEnum.httpToolSet + ? undefined + : tool.version }) ) ]); @@ -622,15 +565,6 @@ export const getAgentRuntimeTools = async ({ }); return []; } - if (tool.toolConfig) { - const runtimeMcpToolSet = toolNode.toolConfig?.mcpToolSet; - toolNode.toolConfig = { - ...tool.toolConfig, - // MCP URL 与密钥仅存在运行态,历史 preview 快照不能覆盖。 - ...(runtimeMcpToolSet ? { mcpToolSet: runtimeMcpToolSet } : {}) - }; - } - const legacyDefaultMode = tool.inputs === undefined ? isSystemOrCommercialToolId(tool.id) @@ -766,8 +700,11 @@ export const getAgentRuntimeTools = async ({ id: runtimeId, name: child.name, avatar: child.avatar, - // MCP/HTTP 子工具节点默认 version 为空;固定版本由父工具集决定。 - version: toolNode.version ?? child.version, + // MCP/HTTP tools have no workflow version. + version: + child.toolConfig?.mcpTool || child.toolConfig?.httpTool + ? '' + : (toolNode.version ?? child.version), toolConfig: child.toolConfig, inputs, agentGeneratedInputKeys: compiledRuntime.agentGeneratedKeys, @@ -799,38 +736,44 @@ export const getAgentRuntimeTools = async ({ return children.map((child) => buildSubApp(child)); } else if (mcpToolsetVal || isLegacyMcpToolSet) { - // 新版 MCP toolset 在当前版本节点保存 toolList;旧版数据只有子 App 存 toolData。 - const finalToolList = await getMcpToolListWithRuntimeSchema({ + const toolSetId = + (mcpToolsetVal && 'toolId' in mcpToolsetVal ? mcpToolsetVal.toolId : undefined) ?? + String(authApp?._id ?? ''); + const toolSetApp = await getRuntimeToolSetApp({ app: authApp, - toolList: mcpToolsetVal?.toolList + toolSetId }); + const finalToolList = toolSetApp ? await getMCPChildren(toolSetApp) : []; - const toolSetId = toolNode.pluginId || pluginId; + const runtimeToolSetId = toolSetId || toolNode.pluginId || pluginId; const children = finalToolList.map((tool, index) => { const newToolNode = getMCPToolRuntimeNode({ - toolSetId, + toolSetId: runtimeToolSetId, toolsetName: toolNode.name, - nodeId: `${toolSetId}${index}`, + nodeId: `${runtimeToolSetId}${index}`, avatar: toolNode.avatar, - tool, - mcpToolSet: buildMcpRuntimeToolSet({ - mcpToolSet: mcpToolsetVal, - toolList: finalToolList as RuntimeMcpTool[], - selectedTool: tool as RuntimeMcpTool - }) + tool }); return newToolNode; }); return children.map((child) => buildSubApp(child)); } else if (httpToolsetVal) { - // HTTP toolset 的 requestSchema 在 getHTTPToolRuntimeNode 中写入 jsonSchema。 - const children = httpToolsetVal.toolList.map((tool: HttpToolConfigType, index) => { + const toolSetId = + ('toolId' in httpToolsetVal ? httpToolsetVal.toolId : undefined) ?? + String(authApp?._id ?? ''); + const toolSetApp = await getRuntimeToolSetApp({ + app: authApp, + toolSetId + }); + const toolList = toolSetApp ? await getHTTPToolList(toolSetApp) : []; + const runtimeToolSetId = toolSetId || toolNode.pluginId || pluginId; + const children = toolList.map((tool: HttpToolConfigType, index) => { const newToolNode = getHTTPToolRuntimeNode({ tool, - nodeId: `${pluginId}${index}`, + nodeId: `${runtimeToolSetId}${index}`, avatar: toolNode.avatar, - toolSetId: pluginId, + toolSetId: runtimeToolSetId, toolsetName: toolNode.name }); return newToolNode; diff --git a/packages/service/core/workflow/dispatch/child/runTool.ts b/packages/service/core/workflow/dispatch/child/runTool.ts index 4bebc9fc7293..2c27746c5596 100644 --- a/packages/service/core/workflow/dispatch/child/runTool.ts +++ b/packages/service/core/workflow/dispatch/child/runTool.ts @@ -4,17 +4,25 @@ import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runti import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; import type { DispatchNodeResultType, ModuleDispatchProps } from '../../types/runtime'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { assertMCPUrlNotInternal, MCPClient } from '../../../app/mcp'; +import { assertMCPUrlNotInternal, getMCPChildren, MCPClient } from '../../../app/mcp'; import { getSecretValue } from '../../../../common/secret/utils'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type'; +import { getHTTPToolRuntimeSchemas } from '@fastgpt/global/core/app/tool/httpTool/utils'; import { assertToolRuntimeParams } from '@fastgpt/global/core/app/tool/runtime'; import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants'; import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type'; import { pushTrack } from '../../../../common/middle/tracks/utils'; import { getNodeErrResponse } from '../utils'; -import { getAppVersionById } from '../../../../core/app/version/controller'; -import { runHTTPTool } from '../../../app/http'; +import { getHTTPToolList, runHTTPTool } from '../../../app/http'; +import { + decodeHttpToolSetNodesFromStorage, + decodeMcpToolSetNodesFromStorage +} from '../../../app/jsonSchemaStorage'; +import { + HttpToolSetRuntimeConfigSchema, + McpToolSetRuntimeConfigSchema +} from '@fastgpt/global/core/workflow/type/node'; import { getWorkflowContext } from '../../utils/context'; import { getToolNameCandidates, @@ -64,7 +72,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise { - await authAppByTmbId({ - tmbId: runningAppInfo.tmbId, - appId: parentId, - per: ReadPermissionVal - }); + return ( + await authAppByTmbId({ + tmbId: runningAppInfo.tmbId, + appId: parentId, + per: ReadPermissionVal + }) + ).app; }; // run system tool @@ -273,15 +283,19 @@ export const dispatchRunTool = async (props: RunToolProps): Promise mcpToolSet.toolList.find((tool) => tool.name === name)) + .map((name) => mcpToolList.find((tool) => tool.name === name)) .find(Boolean); if (!mcpTool) throw new Error(`MCP tool ${toolName} not found`); - const { headerSecret, url } = mcpToolSet; + const url = mcpToolSet?.url ?? mcpTool.url; + const headerSecret = mcpToolSet?.headerSecret ?? mcpTool.headerSecret; + if (!url) throw new Error('MCP tool set URL is missing'); await assertMCPUrlNotInternal(url); @@ -314,18 +328,16 @@ export const dispatchRunTool = async (props: RunToolProps): Promise toolList?.find((tool: HttpToolConfigType) => tool.name === name)) @@ -335,8 +347,10 @@ export const dispatchRunTool = async (props: RunToolProps): Promise item.id === formatResponseData?.id); const childResponsesForQueue = this.data.nodeResponseSink - ? childResponsesForDisplay.flatMap( - (item) => - persistedNodeResponses.filter((persistedItem) => persistedItem.id === item.id) + ? childResponsesForDisplay.flatMap((item) => + persistedNodeResponses.filter((persistedItem) => persistedItem.id === item.id) ) : childResponsesForDisplay; const shouldDropPersistedNodeResponses = !!this.data.nodeResponseSink; @@ -1630,6 +1629,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise { */ export const rewriteRuntimeWorkFlow = async ({ teamId, + tmbId, nodes, edges, lang }: { teamId: string; + tmbId: string; nodes: RuntimeNodeItemType[]; edges: RuntimeEdgeItemType[]; lang?: localeType; @@ -586,39 +585,27 @@ export const rewriteRuntimeWorkFlow = async ({ }; }; - type RuntimeMcpToolSet = NonNullable< - NonNullable['mcpToolSet'] - >; + const getAuthorizedToolSet = async (toolSetId: string) => { + try { + return ( + await authAppByTmbId({ + tmbId, + appId: toolSetId, + per: ReadPermissionVal + }) + ).app; + } catch { + return undefined; + } + }; + type RuntimeMcpTool = McpToolConfigType & { url?: string; - headerSecret?: RuntimeMcpToolSet['headerSecret']; + headerSecret?: McpToolSetRuntimeConfigType['headerSecret']; id?: string; avatar?: string; }; - /** 将当前或旧版 MCP 资源投影为子工具执行所需的 canonical ToolSet。 */ - const buildMcpRuntimeToolSet = ({ - currentToolSet, - toolList, - selectedTool - }: { - currentToolSet?: RuntimeMcpToolSet; - toolList: RuntimeMcpTool[]; - selectedTool?: RuntimeMcpTool; - }): RuntimeMcpToolSet | undefined => { - const url = selectedTool?.url ?? currentToolSet?.url; - if (!url) return undefined; - - const headerSecret = selectedTool?.headerSecret ?? currentToolSet?.headerSecret; - return { - url, - ...(headerSecret ? { headerSecret } : {}), - toolList: toolList.map( - ({ url: _url, headerSecret: _headerSecret, id: _id, avatar: _avatar, ...tool }) => tool - ) - }; - }; - /* ToolSet 展开 */ // TODO: 待性能优化 const parseToolset = async () => { @@ -660,13 +647,15 @@ export const rewriteRuntimeWorkFlow = async ({ pushEdges(runtimeNode.nodeId); }); } else if (mcpToolsetVal) { - const app = await MongoApp.findOne({ _id: toolSetNode.pluginId }).lean(); + // 旧调试状态可能仍携带空 toolId,占位值不能阻止按 pluginId 加载资源。 + const toolSetId = + ('toolId' in mcpToolsetVal ? mcpToolsetVal.toolId : undefined) || toolSetNode.pluginId; + if (!toolSetId) continue; + + const app = await getAuthorizedToolSet(toolSetId); if (!app) continue; const toolList = (await getMCPChildren(app)) as RuntimeMcpTool[]; - const currentToolSet = app.modules?.[0]?.toolConfig?.mcpToolSet; - const toolSetId = toolSetNode.pluginId; - if (!toolSetId) continue; toolList.forEach((tool, index) => { const newToolNode = initToolSetChildNode( getMCPToolRuntimeNode({ @@ -674,19 +663,19 @@ export const rewriteRuntimeWorkFlow = async ({ toolSetId, toolsetName: toolSetNode.name, avatar: toolSetNode.avatar, - tool, - mcpToolSet: buildMcpRuntimeToolSet({ - currentToolSet, - toolList, - selectedTool: tool - }) + tool }) ); nodes.push(newToolNode); pushEdges(newToolNode.nodeId); }); } else if (httpToolsetVal) { - const app = await MongoApp.findOne({ _id: toolSetNode.pluginId }).lean(); + const toolSetId = + ('toolId' in httpToolsetVal ? httpToolsetVal.toolId : undefined) || + toolSetNode.pluginId; + if (!toolSetId) continue; + + const app = await getAuthorizedToolSet(toolSetId); if (!app) continue; const toolList = await getHTTPToolList(app); @@ -697,7 +686,7 @@ export const rewriteRuntimeWorkFlow = async ({ tool, nodeId: `${toolSetNode.nodeId}${index}`, avatar: toolSetNode.avatar, - toolSetId: toolSetNode.pluginId!, + toolSetId, toolsetName: toolSetNode.name }) ); @@ -734,26 +723,18 @@ export const rewriteRuntimeWorkFlow = async ({ }) .filter(Boolean) as { toolsetId: string; toolName: string }[]; // 批量获取 toolset,避免每个工具节点都单独查询一次数据库。 - const toolsets = await getMcpToolsets({ - teamId, - ids: parseMcpToolConfigs.map((config) => config.toolsetId), - field: { - _id: true, - teamId: true, - avatar: true, - modules: true - } - }); - const toolListMap = new Map< - string, - { toolList: RuntimeMcpTool[]; currentToolSet?: RuntimeMcpToolSet } - >(); + const toolsets = ( + await Promise.all( + Array.from(new Set(parseMcpToolConfigs.map((config) => config.toolsetId))).map( + getAuthorizedToolSet + ) + ) + ).filter((toolset): toolset is NonNullable => !!toolset); + const toolListMap = new Map(); await Promise.all( toolsets.map(async (toolset) => { - const currentToolSet = toolset.modules?.[0]?.toolConfig?.mcpToolSet; - const currentToolList = currentToolSet?.toolList; - const toolList = (currentToolList ?? (await getMCPChildren(toolset))) as RuntimeMcpTool[]; - toolListMap.set(String(toolset._id), { toolList, currentToolSet }); + const toolList = (await getMCPChildren(toolset)) as RuntimeMcpTool[]; + toolListMap.set(String(toolset._id), toolList); }) ); mcpToolNodes.forEach((node) => { @@ -761,23 +742,12 @@ export const rewriteRuntimeWorkFlow = async ({ if (!mcpTool) return; const parseResult = parsetMcpToolConfig(mcpTool); if (!parseResult) return; - const toolset = toolListMap.get(parseResult.toolsetId); - if (!toolset) return; - const toolRaw = toolset.toolList.find((tool) => tool.name === parseResult.toolName); + const toolList = toolListMap.get(parseResult.toolsetId); + if (!toolList) return; + const toolRaw = toolList.find((tool) => tool.name === parseResult.toolName); if (!toolRaw) return; node.jsonSchema = toolRaw.inputSchema; node.intro = toolRaw.description; - const mcpToolSet = buildMcpRuntimeToolSet({ - currentToolSet: toolset.currentToolSet, - toolList: toolset.toolList, - selectedTool: toolRaw - }); - if (mcpToolSet) { - node.toolConfig = { - ...node.toolConfig, - mcpToolSet - }; - } mergeToolNodeInputs({ node, jsonSchema: toolRaw.inputSchema, schemaType: 'mcp' }); }); }; @@ -794,25 +764,25 @@ export const rewriteRuntimeWorkFlow = async ({ }) .filter(Boolean) as { toolsetId: string; toolName: string }[]; // 批量获取 toolset,避免每个工具节点都单独查询一次数据库。 - const toolsets = await getHttpToolsets({ - teamId, - ids: parseHttpToolConfigs.map((config) => config.toolsetId), - field: { - _id: true, - modules: true - } - }); - const toolsetMap = new Map(); - toolsets.forEach((toolset) => { - toolsetMap.set(String(toolset._id), toolset); - }); + const toolsets = ( + await Promise.all( + Array.from(new Set(parseHttpToolConfigs.map((config) => config.toolsetId))).map( + getAuthorizedToolSet + ) + ) + ).filter((toolset): toolset is NonNullable => !!toolset); + const toolListMap = new Map(); + await Promise.all( + toolsets.map(async (toolset) => { + toolListMap.set(String(toolset._id), await getHTTPToolList(toolset)); + }) + ); httpToolNodes.forEach((node) => { const httpTool = node.toolConfig?.httpTool; if (!httpTool) return; const parseResult = parseHttpToolConfig(httpTool); if (!parseResult) return; - const toolset = toolsetMap.get(parseResult.toolsetId); - const toolList = toolset?.modules?.[0].toolConfig?.httpToolSet?.toolList; + const toolList = toolListMap.get(parseResult.toolsetId); if (!toolList) return; const toolRaw = toolList.find((tool) => tool.name === parseResult.toolName); if (!toolRaw) return; diff --git a/packages/service/test/core/app/http.test.ts b/packages/service/test/core/app/http.test.ts index c4f987e965b4..66f2f5f4586b 100644 --- a/packages/service/test/core/app/http.test.ts +++ b/packages/service/test/core/app/http.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { runHTTPTool } from '@fastgpt/service/core/app/http'; +import { getHTTPToolList, runHTTPTool } from '@fastgpt/service/core/app/http'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils'; import { serviceEnv } from '@fastgpt/service/env'; @@ -15,6 +16,39 @@ describe('SSRF Vulnerability Fix Tests', () => { serviceEnv.CHECK_INTERNAL_IP = originalCheckInternalIp; }); + describe('getHTTPToolList', () => { + it('should read tools when legacy customHeaders has a non-string value', async () => { + const result = await getHTTPToolList({ + _id: 'http-toolset', + type: AppTypeEnum.httpToolSet, + modules: [ + { + toolConfig: { + httpToolSet: { + customHeaders: false, + toolList: [ + { + name: 'search', + description: 'Search', + path: '/search', + method: 'GET' + } + ] + } + } + } + ] + } as any); + + expect(result).toMatchObject([ + { + name: 'search', + id: 'http-http-toolset/search' + } + ]); + }); + }); + describe('AWS Metadata Endpoint Protection', () => { it('should block AWS metadata endpoint (169.254.169.254)', async () => { const result = await runHTTPTool({ diff --git a/packages/service/test/core/app/mcp.test.ts b/packages/service/test/core/app/mcp.test.ts index 4a2b662bb6c6..e6647c6791a7 100644 --- a/packages/service/test/core/app/mcp.test.ts +++ b/packages/service/test/core/app/mcp.test.ts @@ -28,8 +28,10 @@ import { getMCPChildren } from '../../../core/app/mcp'; import type { AppSchemaType } from '@fastgpt/global/core/app/type'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { PRIVATE_URL_TEXT } from '../../../common/system/utils'; import { serviceEnv } from '../../../env'; +import { getSecretValue, storeSecretValue } from '../../../common/secret/utils'; // Access private client via prototype for spying const getPrivateClient = (mcpClient: MCPClient) => @@ -690,6 +692,7 @@ describe('getMCPChildren', () => { _id: 'app123', avatar: '/icon.png', teamId: 'team1', + type: AppTypeEnum.mcpToolSet, modules: [ { toolConfig: { @@ -736,6 +739,7 @@ describe('getMCPChildren', () => { _id: 'app123', avatar: '/icon.png', teamId: 'team1', + type: AppTypeEnum.mcpToolSet, modules: [ { toolConfig: { @@ -755,11 +759,116 @@ describe('getMCPChildren', () => { expect(result).toEqual([]); }); + it.each([ + { headerSecret: undefined, expected: undefined, headers: {} }, + { headerSecret: null, expected: undefined, headers: {} }, + { headerSecret: {}, expected: {}, headers: {} }, + { + headerSecret: { value: 'legacy-token' }, + expected: { Authorization: { value: 'legacy-token' } }, + headers: { Authorization: 'legacy-token' } + }, + { + headerSecret: { Authorization: { value: 'legacy-token' }, 'X-Key': { value: 'api-key' } }, + expected: { Authorization: { value: 'legacy-token' }, 'X-Key': { value: 'api-key' } }, + headers: { Authorization: 'legacy-token', 'X-Key': 'api-key' } + }, + { + headerSecret: { value: { value: 'header-named-value' } }, + expected: { value: { value: 'header-named-value' } }, + headers: { value: 'header-named-value' } + } + ])( + 'normalizes legacy child headers without wrapping an existing map: %j', + async ({ headerSecret, expected, headers }) => { + const children = [ + { + name: 'search', + modules: [ + { + inputs: [ + { + value: { + name: 'search', + description: 'Search', + url: 'https://mcp.example.com', + headerSecret, + inputSchema: { type: 'object' } + } + } + ] + } + ] + } + ]; + const original = structuredClone(children); + mockMongoAppFind.mockReturnValueOnce({ lean: async () => children }); + const [tool] = await getMCPChildren({ + _id: 'legacy-set', + teamId: 'team', + type: AppTypeEnum.mcpToolSet, + avatar: '', + modules: [{ inputs: [] }] + } as unknown as AppSchemaType); + expect(tool.headerSecret).toEqual(expected); + expect(getSecretValue({ storeSecret: tool.headerSecret })).toEqual(headers); + expect(children).toEqual(original); + } + ); + + it.each(['map', 'single'] as const)( + 'keeps encrypted legacy %s headers decryptable', + async (shape) => { + const encrypted = storeSecretValue({ + Authorization: { value: 'legacy-token' }, + 'X-Key': { value: 'api-key' } + }); + const headerSecret = shape === 'map' ? encrypted : encrypted.Authorization; + mockMongoAppFind.mockReturnValueOnce({ + lean: async () => [ + { + name: 'search', + modules: [ + { + inputs: [ + { + value: { + name: 'search', + description: 'Search', + url: 'https://mcp.example.com', + headerSecret + } + } + ] + } + ] + } + ] + }); + const [tool] = await getMCPChildren({ + _id: 'legacy-set', + teamId: 'team', + type: AppTypeEnum.mcpToolSet, + avatar: '', + modules: [{ inputs: [] }] + } as unknown as AppSchemaType); + expect(tool.headerSecret).toEqual( + shape === 'map' ? encrypted : { Authorization: encrypted.Authorization } + ); + expect(getSecretValue({ storeSecret: tool.headerSecret })).toEqual( + shape === 'map' + ? { Authorization: 'legacy-token', 'X-Key': 'api-key' } + : { Authorization: 'legacy-token' } + ); + } + ); + it('should query MongoApp for old MCP format', async () => { const app = { _id: 'app456', avatar: '/old-icon.png', teamId: 'team2', + type: AppTypeEnum.mcpToolSet, modules: [ { toolConfig: undefined, @@ -806,6 +915,7 @@ describe('getMCPChildren', () => { _id: 'app789', avatar: '/icon.png', teamId: 'team3', + type: AppTypeEnum.mcpToolSet, modules: [{ toolConfig: undefined, inputs: [], outputs: [] }] } as unknown as AppSchemaType; @@ -814,4 +924,30 @@ describe('getMCPChildren', () => { const result = await getMCPChildren(app); expect(result).toEqual([]); }); + + it('should ignore a non-MCP app even when its modules contain an MCP config', async () => { + const app = { + _id: 'workflow-app', + avatar: '/icon.png', + teamId: 'team3', + type: AppTypeEnum.workflow, + modules: [ + { + toolConfig: { + mcpToolSet: { + url: 'https://mcp.test', + toolList: [] + } + }, + inputs: [], + outputs: [] + } + ] + } as unknown as AppSchemaType; + + const result = await getMCPChildren(app); + + expect(result).toEqual([]); + expect(mockMongoAppFind).not.toHaveBeenCalled(); + }); }); diff --git a/packages/service/test/core/app/tool/utils/client.test.ts b/packages/service/test/core/app/tool/utils/client.test.ts index 72cd8141cf89..9e3fb82f5718 100644 --- a/packages/service/test/core/app/tool/utils/client.test.ts +++ b/packages/service/test/core/app/tool/utils/client.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { getToolConfigStatus } from '@fastgpt/global/core/app/formEdit/utils'; const mocks = vi.hoisted(() => ({ findById: vi.fn(), + find: vi.fn(), getAppVersionById: vi.fn(), getSystemToolDetail: vi.fn() })); vi.mock('@fastgpt/service/core/app/schema', () => ({ MongoApp: { - findById: mocks.findById + findById: mocks.findById, + find: mocks.find } })); @@ -29,12 +32,166 @@ vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({ import { getClientToolPreviewNode } from '@fastgpt/service/core/app/tool/utils/client'; +const runtimeSchemaFieldNames = new Set([ + 'inputSchema', + 'outputSchema', + 'requestSchema', + 'responseSchema', + 'secretSchema', + 'jsonSchema', + 'customJsonSchema', + 'apiSchemaStr' +]); + +const getRuntimeSchemaFieldPaths = (value: unknown, path = '$'): string[] => { + if (Array.isArray(value)) { + return value.flatMap((item, index) => getRuntimeSchemaFieldPaths(item, `${path}[${index}]`)); + } + if (!value || typeof value !== 'object') return []; + + return Object.entries(value).flatMap(([key, item]) => [ + ...(runtimeSchemaFieldNames.has(key) ? [`${path}.${key}`] : []), + ...getRuntimeSchemaFieldPaths(item, `${path}.${key}`) + ]); +}; + describe('getClientToolPreviewNode', () => { beforeEach(() => { vi.clearAllMocks(); }); + it.each(['mcp', 'http'] as const)( + 'preserves schema-named business defaults in a %s child preview', + async (source) => { + const appId = '507f1f77bcf86cd799439011'; + const businessValue = { + inputSchema: { title: 'business value' }, + nested: [{ requestSchema: 'payload', customJsonSchema: false, jsonSchema: null }], + outputSchema: 0, + responseSchema: '', + secretSchema: [], + apiSchemaStr: 'data' + }; + const tool = { + name: 'search', + description: 'Search', + path: '/search', + method: 'POST', + inputSchema: { + type: 'object', + properties: { + payload: { type: 'object', default: businessValue } + } + } + }; + const app = { + _id: appId, + teamId: '507f1f77bcf86cd799439012', + type: source === 'mcp' ? AppTypeEnum.mcpToolSet : AppTypeEnum.httpToolSet, + name: 'Tools', + avatar: 'tools.svg', + intro: '', + modules: [ + { + toolConfig: { + [source === 'mcp' ? 'mcpToolSet' : 'httpToolSet']: { + url: 'https://mcp.example.com', + toolList: [tool] + } + } + } + ] + }; + const original = structuredClone(app); + mocks.findById.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(app) }); + + const preview = await getClientToolPreviewNode({ appId: `${source}-${appId}/search` }); + expect(preview.inputs[0].defaultValue).toEqual(businessValue); + expect(preview.inputs[0]).not.toHaveProperty('customJsonSchema'); + expect(preview).not.toHaveProperty('jsonSchema'); + expect(preview.toolConfig).toEqual({ + [source === 'mcp' ? 'mcpTool' : 'httpTool']: { toolId: `${source}-${appId}/search` } + }); + expect(app).toEqual(original); + } + ); + + it.each(['mcp', 'http'] as const)( + 'projects %s toolset definitions without traversing IO business values', + async (source) => { + const appId = '507f1f77bcf86cd799439011'; + const businessValue = { requestSchema: { nested: [{ customJsonSchema: 'data' }] } }; + const toolSetKey = source === 'mcp' ? 'mcpToolSet' : 'httpToolSet'; + mocks.findById.mockReturnValueOnce({ + lean: vi.fn().mockResolvedValue({ + _id: appId, + teamId: '507f1f77bcf86cd799439012', + type: source === 'mcp' ? AppTypeEnum.mcpToolSet : AppTypeEnum.httpToolSet, + name: 'Tools', + avatar: 'tools.svg', + intro: '', + modules: [ + { + flowNodeType: 'toolSet', + inputs: [ + { + key: 'payload', + label: 'Payload', + renderTypeList: ['input'], + value: businessValue, + defaultValue: businessValue, + customJsonSchema: { type: 'object' } + } + ], + outputs: [ + { + id: 'result', + key: 'result', + label: 'Result', + type: 'static', + value: businessValue, + defaultValue: businessValue + } + ], + toolConfig: { + [toolSetKey]: { + url: 'https://mcp.example.com', + apiSchemaStr: 'raw-schema', + toolList: [ + { + name: 'search', + description: 'Search', + path: '/search', + method: 'POST', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + requestSchema: { type: 'object' }, + responseSchema: { type: 'object' }, + customJsonSchema: { type: 'object' } + } + ] + } + } + } + ] + }) + }); + + const preview = await getClientToolPreviewNode({ appId }); + expect(preview.inputs[0]).not.toHaveProperty('customJsonSchema'); + expect(preview.inputs[0].value).toEqual(businessValue); + expect(preview.inputs[0].defaultValue).toEqual(businessValue); + expect(preview.outputs[0].value).toEqual(businessValue); + expect(preview.outputs[0].defaultValue).toEqual(businessValue); + expect(getRuntimeSchemaFieldPaths(preview.toolConfig)).toEqual([]); + expect(preview.toolConfig).toEqual({ + [toolSetKey]: { toolId: appId, toolList: [{ name: 'search', description: 'Search' }] } + }); + } + ); + it('uses explicit debug source for system tool preview without encoding appId', async () => { + const businessValue = { requestSchema: 'payload', jsonSchema: { customJsonSchema: false } }; mocks.getSystemToolDetail.mockResolvedValueOnce({ id: 'systemTool-weather', version: '1.0.0', @@ -50,7 +207,11 @@ describe('getClientToolPreviewNode', () => { currentCost: 0, systemKeyCost: 0, hasTokenFee: false, - hasSystemSecret: false + hasSystemSecret: false, + inputSchema: { + type: 'object', + properties: { payload: { type: 'object', default: businessValue } } + } }); const result = await getClientToolPreviewNode({ @@ -68,6 +229,11 @@ describe('getClientToolPreviewNode', () => { }); expect(result.pluginId).toBe('systemTool-weather'); expect(result.source).toBe('debug:tmbId:tmb-1'); + expect(result.inputs[0].defaultValue).toEqual(businessValue); + expect(result.inputs[0].customJsonSchema).toMatchObject({ + type: 'object', + default: businessValue + }); }); it('omits runtime schema fields from client preview response', async () => { @@ -78,31 +244,28 @@ describe('getClientToolPreviewNode', () => { type: AppTypeEnum.httpToolSet, name: 'HTTP Tools', avatar: 'http.svg', - intro: 'HTTP toolset' - }) - }); - mocks.getAppVersionById.mockResolvedValueOnce({ - nodes: [ - { - toolConfig: { - httpToolSet: { - toolList: [ - { - name: 'search', - description: 'Search tool', - requestSchema: { type: 'object', properties: { q: { type: 'string' } } }, - inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, - outputSchema: { type: 'object', properties: { result: { type: 'string' } } } - } - ] + intro: 'HTTP toolset', + modules: [ + { + toolConfig: { + httpToolSet: { + apiSchemaStr: '{"openapi":"3.1.0"}', + toolList: [ + { + name: 'search', + description: 'Search tool', + requestSchema: { type: 'object', properties: { q: { type: 'string' } } }, + inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, + outputSchema: { type: 'object', properties: { result: { type: 'string' } } }, + path: '/search', + method: 'GET' + } + ] + } } } - } - ], - edges: [], - chatConfig: {}, - versionId: 'version-id', - versionName: 'Version 1' + ] + }) }); const result = await getClientToolPreviewNode({ @@ -114,11 +277,157 @@ describe('getClientToolPreviewNode', () => { expect(result).not.toHaveProperty('inputSchema'); expect(result).not.toHaveProperty('outputSchema'); expect(result).not.toHaveProperty('secretSchema'); + expect(result.inputs[0]).not.toHaveProperty('customJsonSchema'); expect(result.toolConfig?.httpTool).toEqual({ toolId: 'http-507f1f77bcf86cd799439011/search' }); expect(result.inputs[0]?.key).toBe('q'); expect((result as any).jsonSchema).toBeUndefined(); + expect(getRuntimeSchemaFieldPaths(result)).toEqual([]); + }); + + it.each([undefined, {}])( + 'removes only legacy MCP hidden configuration when adding a node (toolConfig: %j)', + async (toolConfig) => { + const appId = '507f1f77bcf86cd799439031'; + const tool = { + name: 'search', + description: 'Search', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } } + }; + const headers = { Authorization: { value: 'legacy-token' } }; + const businessValue = { requestSchema: 'business-data' }; + const app = { + _id: appId, + teamId: '507f1f77bcf86cd799439032', + type: AppTypeEnum.mcpToolSet, + name: 'Legacy', + avatar: 'mcp.svg', + modules: [ + { + flowNodeType: 'toolSet', + toolConfig, + inputs: [ + { + key: NodeInputKeyEnum.toolSetData, + label: 'Old config', + renderTypeList: ['hidden'], + value: { url: 'https://example.com/mcp', headerSecret: headers, toolList: [tool] } + }, + { key: 'options', label: 'Options', renderTypeList: ['input'], value: businessValue }, + { + key: NodeInputKeyEnum.toolSetData, + label: 'User field', + renderTypeList: ['input'], + value: 'ordinary-value' + } + ], + outputs: [] + } + ] + }; + const original = structuredClone(app); + mocks.findById.mockReturnValueOnce({ lean: async () => app }); + mocks.find.mockReturnValueOnce({ + lean: async () => [ + { + name: tool.name, + modules: [ + { + inputs: [ + { value: { ...tool, url: 'https://example.com/mcp', headerSecret: headers } } + ] + } + ] + } + ] + }); + const preview = await getClientToolPreviewNode({ appId, versionId: '' }); + expect(preview.toolConfig).toEqual({ + mcpToolSet: { toolId: appId, toolList: [{ name: 'search', description: 'Search' }] } + }); + expect(preview.inputs).toHaveLength(2); + expect(preview.inputs[0]).toMatchObject({ key: 'options', value: businessValue }); + expect(preview.inputs[1]).toMatchObject({ + key: NodeInputKeyEnum.toolSetData, + value: 'ordinary-value' + }); + expect(JSON.stringify(preview)).not.toContain('inputSchema'); + expect(app).toEqual(original); + } + ); + + it.each([ + { toolList: [] }, + { toolList: [{ name: 'search', description: 'Search', inputSchema: { type: 'object' } }] } + ])('adds an inline MCP toolset with an empty legacy id: %j', async ({ toolList }) => { + const appId = '507f1f77bcf86cd799439031'; + const app = { + _id: appId, + teamId: '507f1f77bcf86cd799439032', + type: AppTypeEnum.mcpToolSet, + name: 'Legacy', + avatar: 'mcp.svg', + modules: [ + { + flowNodeType: 'toolSet', + toolConfig: { mcpToolSet: { toolId: '', url: 'https://example.com/mcp', toolList } }, + inputs: [], + outputs: [] + } + ] + }; + const original = structuredClone(app); + mocks.findById.mockReturnValueOnce({ lean: async () => app }); + const preview = await getClientToolPreviewNode({ appId, versionId: '' }); + expect(preview.toolConfig?.mcpToolSet).toMatchObject({ toolId: appId }); + expect(getRuntimeSchemaFieldPaths(preview)).toEqual([]); + expect(app).toEqual(original); + }); + + it('hydrates legacy MCP toolset data under toolConfig', async () => { + const appId = '507f1f77bcf86cd799439031'; + mocks.findById.mockReturnValueOnce({ + lean: vi.fn().mockResolvedValue({ + _id: appId, + teamId: '507f1f77bcf86cd799439032', + type: AppTypeEnum.mcpToolSet, + name: 'Legacy MCP Tools', + avatar: 'mcp.svg', + intro: 'Legacy MCP toolset', + modules: [{ flowNodeType: 'toolSet', inputs: [] }] + }) + }); + mocks.find.mockReturnValueOnce({ + lean: vi.fn().mockResolvedValue([ + { + name: 'search', + modules: [ + { + inputs: [ + { + value: { + name: 'search', + description: 'Search tool', + inputSchema: { type: 'object' }, + url: 'https://mcp.example.com' + } + } + ] + } + ] + } + ]) + }); + + const result = await getClientToolPreviewNode({ appId, lang: 'en' }); + + expect(result.toolConfig?.mcpToolSet).toMatchObject({ + toolId: appId, + toolList: [{ name: 'search', description: 'Search tool' }] + }); + expect(JSON.stringify(result.toolConfig)).not.toContain('inputSchema'); + expect(getRuntimeSchemaFieldPaths(result)).toEqual([]); }); it('applies defaultToAgentGenerated over a workflow plugin input selection', async () => { diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/index.test.ts index 74aa52080e4a..a3712d55249e 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/index.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/index.test.ts @@ -6,6 +6,8 @@ import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/sys const { authAppByTmbIdMock, getAppVersionByIdMock, + getHTTPToolListMock, + getMCPChildrenMock, runHTTPToolMock, mcpToolCallMock, runToolStreamMock, @@ -13,6 +15,8 @@ const { } = vi.hoisted(() => ({ authAppByTmbIdMock: vi.fn(), getAppVersionByIdMock: vi.fn(), + getHTTPToolListMock: vi.fn(), + getMCPChildrenMock: vi.fn(), runHTTPToolMock: vi.fn(), mcpToolCallMock: vi.fn(), runToolStreamMock: vi.fn(), @@ -28,14 +32,16 @@ vi.mock('@fastgpt/service/core/app/version/controller', () => ({ })); vi.mock('@fastgpt/service/core/app/http', () => ({ + getHTTPToolList: getHTTPToolListMock, runHTTPTool: runHTTPToolMock })); vi.mock('@fastgpt/service/core/app/mcp', () => ({ assertMCPUrlNotInternal: vi.fn(), - MCPClient: vi.fn().mockImplementation(() => ({ - toolCall: mcpToolCallMock - })) + getMCPChildren: getMCPChildrenMock, + MCPClient: vi.fn(function () { + return { toolCall: mcpToolCallMock }; + }) })); vi.mock('@fastgpt/service/common/logger', async () => { @@ -98,7 +104,8 @@ const createDispatchToolProps = ( memberName: 'Attacker member', contact: '', teamId: 'attacker-team', - tmbId: 'attacker-tmb' + // 工具加载和执行都必须使用应用创建者,而不是当前调用者。 + tmbId: 'caller-without-toolset-permission' }, chatId: 'chat', uid: 'uid', @@ -108,13 +115,148 @@ const createDispatchToolProps = ( }) as any; describe('dispatchTool runtime toolset auth', () => { + it.each([ + { name: 'scalar string', requestSchema: { type: 'string' } }, + { name: 'scalar number', requestSchema: { type: 'number' } }, + { name: 'missing', requestSchema: undefined }, + { name: 'empty object', requestSchema: { type: 'object', properties: {} } } + ])('normalizes a $name HTTP requestSchema before final execution', async ({ requestSchema }) => { + const tool = { + name: 'legacy_search', + description: 'Legacy search', + path: '/search', + method: 'GET', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', pattern: '^allowed$' } }, + required: ['query'] + }, + requestSchema + }; + const original = structuredClone(tool); + authAppByTmbIdMock.mockResolvedValue({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + httpToolSet: { baseUrl: 'https://example.com', toolList: [tool] } + } + } + ] + } + }); + getHTTPToolListMock.mockResolvedValue([tool]); + runHTTPToolMock.mockResolvedValue({ data: { ok: true } }); + const toolConfig = { httpTool: { toolId: 'http-victim-toolset/legacy_search' } }; + + const accepted = await dispatchTool(createDispatchToolProps(toolConfig, { query: 'allowed' })); + expect(accepted.errorMessage).toBeUndefined(); + expect(runHTTPToolMock).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + toolPath: '/search', + method: 'GET', + params: { query: 'allowed' } + }) + ); + + runHTTPToolMock.mockClear(); + for (const params of [{}, { query: 123 }, { query: 'blocked' }]) { + const rejected = await dispatchTool(createDispatchToolProps(toolConfig, params)); + expect(rejected.errorMessage).toContain('validation failed'); + } + expect(runHTTPToolMock).not.toHaveBeenCalled(); + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); + expect(tool).toEqual(original); + }); + + it.each(['mcp', 'http'] as const)( + 'validates %s Agent params against freshly loaded definitions instead of stale snapshots', + async (source) => { + const latestSchema = { + type: 'object', + properties: { query: { type: 'string', pattern: '^latest$' } }, + required: ['query'] + }; + const tool = { + name: 'search', + description: 'Search', + path: '/latest', + method: 'POST', + inputSchema: latestSchema, + requestSchema: latestSchema, + staticHeaders: [{ key: 'X-Version', value: 'latest' }] + }; + const key = source === 'mcp' ? 'mcpToolSet' : 'httpToolSet'; + authAppByTmbIdMock.mockResolvedValue({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + [key]: { + url: 'https://latest.example.com/mcp', + baseUrl: 'https://latest.example.com', + toolList: [tool] + } + } + } + ] + } + }); + getMCPChildrenMock.mockResolvedValue([tool]); + getHTTPToolListMock.mockResolvedValue([tool]); + mcpToolCallMock.mockResolvedValue({ ok: true }); + runHTTPToolMock.mockResolvedValue({ data: { ok: true } }); + const config = { + [source === 'mcp' ? 'mcpTool' : 'httpTool']: { toolId: `${source}-victim-toolset/search` }, + [key]: { + url: 'https://stale.example.com', + toolList: [ + { ...tool, inputSchema: { type: 'object' }, requestSchema: { type: 'object' } } + ] + } + }; + const rejected = await dispatchTool(createDispatchToolProps(config, { query: 'stale' })); + expect(rejected.errorMessage).toContain('validation failed'); + expect(mcpToolCallMock).not.toHaveBeenCalled(); + expect(runHTTPToolMock).not.toHaveBeenCalled(); + const accepted = await dispatchTool(createDispatchToolProps(config, { query: 'latest' })); + expect(accepted.errorMessage).toBeUndefined(); + if (source === 'http') { + expect(runHTTPToolMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://latest.example.com', + toolPath: '/latest', + staticHeaders: tool.staticHeaders, + params: { query: 'latest' } + }) + ); + } else { + expect(mcpToolCallMock).toHaveBeenCalledWith({ + toolName: 'search', + params: { query: 'latest' } + }); + } + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); + expect(authAppByTmbIdMock).toHaveBeenCalledWith({ + tmbId: 'attacker-tmb', + appId: 'victim-toolset', + per: ReadPermissionVal + }); + } + ); + beforeEach(() => { vi.clearAllMocks(); authAppByTmbIdMock.mockResolvedValue({ app: { - _id: 'victim-toolset' + _id: 'victim-toolset', + modules: [] } }); + getHTTPToolListMock.mockResolvedValue([]); + getMCPChildrenMock.mockResolvedValue([]); getSystemToolRuntimeMock.mockResolvedValue({ id: 'search', version: '1.0.0', @@ -195,24 +337,35 @@ describe('dispatchTool runtime toolset auth', () => { }); it('should authorize HTTP parent toolset before agent tool execution', async () => { - getAppVersionByIdMock.mockResolvedValueOnce({ - nodes: [ - { - toolConfig: { - httpToolSet: { - baseUrl: 'https://example.com', - toolList: [ - { - name: 'sandbox_echo', - path: '/echo', - method: 'post' - } - ] + authAppByTmbIdMock.mockResolvedValueOnce({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + httpToolSet: { + baseUrl: 'https://example.com', + toolList: [ + { + name: 'sandbox_echo', + description: 'Sandbox echo', + path: '/echo', + method: 'post' + } + ] + } } } - } - ] + ] + } }); + getHTTPToolListMock.mockResolvedValueOnce([ + { + name: 'sandbox_echo', + path: '/echo', + method: 'post' + } + ]); runHTTPToolMock.mockResolvedValueOnce({ data: { ok: true @@ -232,10 +385,7 @@ describe('dispatchTool runtime toolset auth', () => { appId: 'victim-toolset', per: ReadPermissionVal }); - expect(getAppVersionByIdMock).toHaveBeenCalledWith({ - appId: 'victim-toolset', - versionId: undefined - }); + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); expect(runHTTPToolMock).toHaveBeenCalledWith( expect.objectContaining({ baseUrl: 'https://example.com', diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/utils.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/utils.test.ts index cf62142d939b..708b60c19c04 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/utils.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/tool/utils.test.ts @@ -17,12 +17,14 @@ const { authAppByTmbIdMock, getAppVersionByIdMock, getMCPChildrenMock, + getHTTPToolListMock, getSystemToolDetailMock, assertTeamPluginSourceAccessMock } = vi.hoisted(() => ({ authAppByTmbIdMock: vi.fn(), getAppVersionByIdMock: vi.fn(), getMCPChildrenMock: vi.fn(), + getHTTPToolListMock: vi.fn(), getSystemToolDetailMock: vi.fn(), assertTeamPluginSourceAccessMock: vi.fn() })); @@ -39,6 +41,10 @@ vi.mock('@fastgpt/service/core/app/mcp', () => ({ getMCPChildren: getMCPChildrenMock })); +vi.mock('@fastgpt/service/core/app/http', () => ({ + getHTTPToolList: getHTTPToolListMock +})); + vi.mock('@fastgpt/service/support/user/team/controller', () => ({ getTmbInfoByTmbId: vi.fn().mockResolvedValue({ teamId: 'team_1' }) })); @@ -179,17 +185,6 @@ const mcpTool = { inputSchema: mcpInputSchema }; -const fixedVersionMcpToolSet = { - url: 'https://v1.example.com', - headerSecret: { - Authorization: { - value: 'legacy-secret', - secret: '' - } - }, - toolList: [mcpTool] -}; - const mcpToolWithLeadingSlash = { ...mcpTool, name: '/test' @@ -316,7 +311,20 @@ const createPersonalApp = ({ describe('getAgentRuntimeTools schema loading', () => { beforeEach(() => { vi.clearAllMocks(); - getMCPChildrenMock.mockResolvedValue([]); + getMCPChildrenMock.mockImplementation( + async (app: { modules?: Array<{ toolConfig?: NodeToolConfigType }> }) => + app.modules?.[0]?.toolConfig?.mcpToolSet && + 'toolList' in app.modules[0].toolConfig.mcpToolSet + ? app.modules[0].toolConfig.mcpToolSet.toolList + : [] + ); + getHTTPToolListMock.mockImplementation( + async (app: { modules?: Array<{ toolConfig?: NodeToolConfigType }> }) => + app.modules?.[0]?.toolConfig?.httpToolSet && + 'toolList' in app.modules[0].toolConfig.httpToolSet + ? app.modules[0].toolConfig.httpToolSet.toolList + : [] + ); getSystemToolDetailMock.mockReset(); authAppByTmbIdMock.mockImplementation(async ({ appId }: { appId: string }) => { @@ -329,18 +337,7 @@ describe('getAgentRuntimeTools schema loading', () => { async ({ app, versionId }: { app: any; versionId?: string }) => ({ versionId: versionId ?? '', versionName: app.name, - nodes: - app._id === 'mcp_app' && versionId === 'fixed-version' - ? [ - { - ...app.modules[0], - toolConfig: { - ...app.modules[0].toolConfig, - mcpToolSet: fixedVersionMcpToolSet - } - } - ] - : app.modules, + nodes: app.modules, edges: app.edges, chatConfig: app.chatConfig }) @@ -645,10 +642,10 @@ describe('getAgentRuntimeTools schema loading', () => { expect(latestVersionTools[0]?.version).toBe(''); }); - it('loads MCP toolset children with their input schema', async () => { + it('loads MCP toolset children with their input schema from the current toolset app', async () => { const tools = await getAgentRuntimeTools({ tmbId: 'tmb_1', - tools: [{ id: 'mcp_app', version: 'fixed-version', config: {} }] + tools: [{ id: 'mcp_app', version: 'ignored-version', config: {} }] }); expect(tools).toHaveLength(1); @@ -656,11 +653,8 @@ describe('getAgentRuntimeTools schema loading', () => { expect(tools[0].requestSchema.function.description).toBe('mcp_app name/search: Search docs'); expect(tools[0].requestSchema.function.parameters).toEqual(getModelToolSchema(mcpInputSchema)); expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); - expect(tools[0].version).toBe('fixed-version'); - expect(tools[0].toolConfig?.mcpToolSet).toMatchObject({ - url: fixedVersionMcpToolSet.url, - headerSecret: fixedVersionMcpToolSet.headerSecret - }); + expect(tools[0].version).toBe(''); + expect(tools[0].toolConfig).not.toHaveProperty('mcpToolSet'); expect(tools[0].promptReference).toEqual({ id: 'mcp_app', name: 'mcp_app name' @@ -682,7 +676,7 @@ describe('getAgentRuntimeTools schema loading', () => { expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); }); - it('keeps runtime MCP connection data when an Agent toolset snapshot has no URL', async () => { + it('does not persist runtime MCP connection data in expanded Agent tools', async () => { const tools = await getAgentRuntimeTools({ tmbId: 'tmb_1', tools: [ @@ -699,30 +693,20 @@ describe('getAgentRuntimeTools schema loading', () => { }); expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); - expect(tools[0].toolConfig?.mcpToolSet).toMatchObject({ - url: 'https://current.example.com' - }); + expect(tools[0].toolConfig).not.toHaveProperty('mcpToolSet'); }); - it('uses fixed-version MCP configuration for a selected tool', async () => { + it('uses the current MCP configuration for a selected tool', async () => { const tools = await getAgentRuntimeTools({ tmbId: 'tmb_1', - tools: [{ id: 'mcp-mcp_app/search', version: 'fixed-version', config: {} }] + tools: [{ id: 'mcp-mcp_app/search', version: 'ignored-version', config: {} }] }); expect(tools).toHaveLength(1); - expect(getAppVersionByIdMock).toHaveBeenCalledWith( - expect.objectContaining({ - appId: 'mcp_app', - versionId: 'fixed-version' - }) - ); - expect(tools[0].version).toBe('fixed-version'); + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); + expect(tools[0].version).toBe(''); expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); - expect(tools[0].toolConfig?.mcpToolSet).toMatchObject({ - url: fixedVersionMcpToolSet.url, - headerSecret: fixedVersionMcpToolSet.headerSecret - }); + expect(tools[0].toolConfig).not.toHaveProperty('mcpToolSet'); }); it('uses the dedicated Agent input mode at runtime', async () => { @@ -890,10 +874,10 @@ describe('getAgentRuntimeTools schema loading', () => { }); }); - it('loads HTTP toolset children with their request schema', async () => { + it('loads HTTP toolset children with their request schema from the current toolset app', async () => { const tools = await getAgentRuntimeTools({ tmbId: 'tmb_1', - tools: [{ id: 'http_app', version: 'fixed-version', config: {} }] + tools: [{ id: 'http_app', version: 'ignored-version', config: {} }] }); expect(tools).toHaveLength(1); @@ -904,7 +888,7 @@ describe('getAgentRuntimeTools schema loading', () => { ); expect(tools[0].requestSchema.function.parameters).not.toEqual(httpInputSchema); expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_app/create'); - expect(tools[0].version).toBe('fixed-version'); + expect(tools[0].version).toBe(''); expect(tools[0].promptReference).toEqual({ id: 'http_app', name: 'http_app name' diff --git a/packages/service/test/core/workflow/dispatch/tools/runTool.test.ts b/packages/service/test/core/workflow/dispatch/tools/runTool.test.ts index 2e2a99f60984..0dd738ed3999 100644 --- a/packages/service/test/core/workflow/dispatch/tools/runTool.test.ts +++ b/packages/service/test/core/workflow/dispatch/tools/runTool.test.ts @@ -7,10 +7,13 @@ import { import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants'; +import { MCPClient } from '@fastgpt/service/core/app/mcp'; const { authAppByTmbIdMock, getAppVersionByIdMock, + getHTTPToolListMock, + getMCPChildrenMock, runHTTPToolMock, mcpToolCallMock, runToolStreamMock, @@ -19,6 +22,8 @@ const { } = vi.hoisted(() => ({ authAppByTmbIdMock: vi.fn(), getAppVersionByIdMock: vi.fn(), + getHTTPToolListMock: vi.fn(), + getMCPChildrenMock: vi.fn(), runHTTPToolMock: vi.fn(), mcpToolCallMock: vi.fn(), runToolStreamMock: vi.fn(), @@ -35,11 +40,13 @@ vi.mock('@fastgpt/service/core/app/version/controller', () => ({ })); vi.mock('@fastgpt/service/core/app/http', () => ({ + getHTTPToolList: getHTTPToolListMock, runHTTPTool: runHTTPToolMock })); vi.mock('@fastgpt/service/core/app/mcp', () => ({ assertMCPUrlNotInternal: vi.fn(), + getMCPChildren: getMCPChildrenMock, MCPClient: vi.fn(function () { return { toolCall: mcpToolCallMock }; }) @@ -104,7 +111,8 @@ const createRunToolProps = ( memberName: 'Attacker member', contact: '', teamId: 'attacker-team', - tmbId: 'attacker-tmb' + // 调用者与应用创建者不同;工具引用仍应按应用创建者鉴权。 + tmbId: 'caller-without-toolset-permission' }, variableState: { get: vi.fn() @@ -129,9 +137,12 @@ describe('dispatchRunTool runtime toolset auth', () => { vi.clearAllMocks(); authAppByTmbIdMock.mockResolvedValue({ app: { - _id: 'victim-toolset' + _id: 'victim-toolset', + modules: [] } }); + getHTTPToolListMock.mockResolvedValue([]); + getMCPChildrenMock.mockResolvedValue([]); getSystemToolRuntimeMock.mockResolvedValue({ id: 'search', version: '1.0.0', @@ -208,24 +219,35 @@ describe('dispatchRunTool runtime toolset auth', () => { }); it('should authorize HTTP parent toolset before loading version and running tool', async () => { - getAppVersionByIdMock.mockResolvedValueOnce({ - nodes: [ - { - toolConfig: { - httpToolSet: { - baseUrl: 'https://example.com', - toolList: [ - { - name: 'sandbox_echo', - path: '/echo', - method: 'post' - } - ] + authAppByTmbIdMock.mockResolvedValueOnce({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + httpToolSet: { + baseUrl: 'https://example.com', + toolList: [ + { + name: 'sandbox_echo', + description: 'Sandbox echo', + path: '/echo', + method: 'post' + } + ] + } } } - } - ] + ] + } }); + getHTTPToolListMock.mockResolvedValueOnce([ + { + name: 'sandbox_echo', + path: '/echo', + method: 'post' + } + ]); runHTTPToolMock.mockResolvedValueOnce({ data: { ok: true @@ -245,10 +267,7 @@ describe('dispatchRunTool runtime toolset auth', () => { appId: 'victim-toolset', per: ReadPermissionVal }); - expect(getAppVersionByIdMock).toHaveBeenCalledWith({ - appId: 'victim-toolset', - versionId: undefined - }); + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); expect(runHTTPToolMock).toHaveBeenCalledWith( expect.objectContaining({ baseUrl: 'https://example.com', @@ -264,34 +283,114 @@ describe('dispatchRunTool runtime toolset auth', () => { }); }); - it('should reject invalid HTTP params before invoking the external tool', async () => { - getAppVersionByIdMock.mockResolvedValueOnce({ - nodes: [ - { - toolConfig: { - httpToolSet: { - baseUrl: 'https://example.com', - toolList: [ - { - name: 'sandbox_echo', - path: '/echo', - method: 'post', - requestSchema: { - type: 'object', - properties: { keyword: { type: 'string', pattern: '^allowed$' } }, - required: ['keyword'] + it.each([ + { name: 'scalar string', requestSchema: { type: 'string' } }, + { name: 'scalar number', requestSchema: { type: 'number' } }, + { name: 'missing', requestSchema: undefined }, + { name: 'empty object', requestSchema: { type: 'object', properties: {} } } + ])('normalizes a $name HTTP requestSchema before final execution', async ({ requestSchema }) => { + const tool = { + name: 'legacy_search', + description: 'Legacy search', + path: '/search', + method: 'GET', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', pattern: '^allowed$' } }, + required: ['query'] + }, + requestSchema + }; + const original = structuredClone(tool); + authAppByTmbIdMock.mockResolvedValue({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + httpToolSet: { baseUrl: 'https://example.com', toolList: [tool] } + } + } + ] + } + }); + getHTTPToolListMock.mockResolvedValue([tool]); + runHTTPToolMock.mockResolvedValue({ data: { ok: true } }); + const toolConfig = { httpTool: { toolId: 'http-victim-toolset/legacy_search' } }; + + const accepted = await dispatchRunTool(createRunToolProps(toolConfig, { query: 'allowed' })); + expect(accepted.error).toBeUndefined(); + expect(runHTTPToolMock).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + toolPath: '/search', + method: 'GET', + params: { query: 'allowed' } + }) + ); + + runHTTPToolMock.mockClear(); + for (const params of [{}, { query: 123 }, { query: 'blocked' }]) { + const rejected = await dispatchRunTool(createRunToolProps(toolConfig, params)); + expect(rejected.error?.[NodeOutputKeyEnum.errorText]).toContain('validation failed'); + } + expect(runHTTPToolMock).not.toHaveBeenCalled(); + expect(getAppVersionByIdMock).not.toHaveBeenCalled(); + expect(tool).toEqual(original); + }); + + it('should validate HTTP params with the latest toolset schema instead of a saved snapshot', async () => { + authAppByTmbIdMock.mockResolvedValueOnce({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + httpToolSet: { + baseUrl: 'https://example.com', + toolList: [ + { + name: 'sandbox_echo', + description: 'Sandbox echo', + path: '/echo', + method: 'post', + requestSchema: { + type: 'object', + properties: { keyword: { type: 'string', pattern: '^allowed$' } }, + required: ['keyword'] + } } - } - ] + ] + } } } - } - ] + ] + } }); + getHTTPToolListMock.mockResolvedValueOnce([ + { + name: 'sandbox_echo', + path: '/echo', + method: 'post', + requestSchema: { + type: 'object', + properties: { keyword: { type: 'string', pattern: '^allowed$' } }, + required: ['keyword'] + } + } + ]); + runHTTPToolMock.mockResolvedValueOnce({ data: { ok: true } }); - const result = await dispatchRunTool( - createRunToolProps({ httpTool: { toolId: 'http-victim-toolset/sandbox_echo' } }) + const props = createRunToolProps( + { httpTool: { toolId: 'http-victim-toolset/sandbox_echo' } }, + { keyword: 'blocked' } ); + props.node.jsonSchema = { + type: 'object', + properties: { keyword: { type: 'string' } }, + required: ['keyword'] + }; + + const result = await dispatchRunTool(props); expect(runHTTPToolMock).not.toHaveBeenCalled(); expect(result.error?.[NodeOutputKeyEnum.errorText]).toContain('validation failed'); @@ -319,23 +418,46 @@ describe('dispatchRunTool runtime toolset auth', () => { }); it('should reject invalid MCP params before invoking the external tool', async () => { - const result = await dispatchRunTool( - createRunToolProps({ - mcpTool: { toolId: 'mcp-victim-toolset/search' }, - mcpToolSet: { - url: 'https://mcp.example.com', - toolList: [ - { - name: 'search', - description: 'Search', - inputSchema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'] + authAppByTmbIdMock.mockResolvedValueOnce({ + app: { + _id: 'victim-toolset', + modules: [ + { + toolConfig: { + mcpToolSet: { + url: 'https://mcp.example.com', + toolList: [ + { + name: 'search', + description: 'Search', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] + } + } + ] } } - ] + } + ] + } + }); + getMCPChildrenMock.mockResolvedValueOnce([ + { + name: 'search', + description: 'Search', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] } + } + ]); + + const result = await dispatchRunTool( + createRunToolProps({ + mcpTool: { toolId: 'mcp-victim-toolset/search' } }) ); @@ -352,6 +474,7 @@ describe('dispatchRunTool runtime toolset auth', () => { [legacyKey]: { name: 'search', url: 'https://mcp.example.com', + headerSecret: { Authorization: { value: 'legacy-token' }, 'X-Key': { value: 'api-key' } }, inputSchema: { type: 'object', properties: { query: { type: 'string' } }, @@ -364,6 +487,10 @@ describe('dispatchRunTool runtime toolset auth', () => { const result = await dispatchRunTool(props); expect(result.error).toBeUndefined(); + expect(MCPClient).toHaveBeenCalledWith({ + url: 'https://mcp.example.com', + headers: { Authorization: 'legacy-token', 'X-Key': 'api-key' } + }); expect(mcpToolCallMock).toHaveBeenCalledWith({ toolName: 'search', params: { query: 'fastgpt' } diff --git a/packages/service/test/core/workflow/dispatch/utils.test.ts b/packages/service/test/core/workflow/dispatch/utils.test.ts index 3951821f470c..131d8dd7c776 100644 --- a/packages/service/test/core/workflow/dispatch/utils.test.ts +++ b/packages/service/test/core/workflow/dispatch/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { getWorkflowResponseWrite, @@ -38,6 +38,11 @@ vi.mock('@fastgpt/service/core/workflow/utils', () => ({ mockGetSystemToolRunTimeNodeFromSystemToolset(...args) })); +const mockAuthAppByTmbId = vi.fn(); +vi.mock('@fastgpt/service/support/permission/app/auth', () => ({ + authAppByTmbId: (...args: any[]) => mockAuthAppByTmbId(...args) +})); + const mockMongoAppFindOne = vi.fn(); const mockMongoAppFind = vi.fn(() => ({ lean: vi.fn().mockResolvedValue([]) })); vi.mock('@fastgpt/service/core/app/schema', () => ({ @@ -918,6 +923,40 @@ describe('formatHttpError', () => { }); describe('rewriteRuntimeWorkFlow', () => { + beforeEach(() => { + mockAuthAppByTmbId.mockReset(); + mockMongoAppFindOne.mockReset(); + mockMongoAppFind.mockReset(); + mockMongoAppFind.mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }); + mockAuthAppByTmbId.mockImplementation(async ({ appId }: { appId: string }) => { + const findOneQuery = mockMongoAppFindOne({ _id: appId }); + if (findOneQuery) { + const app = await findOneQuery.lean(); + if (app) return { app }; + } + + const findQuery = mockMongoAppFind({ _id: { $in: [appId] } }); + const [app] = await findQuery.lean(); + if (app) return { app }; + throw new Error('app not found'); + }); + mockGetMCPChildren.mockReset(); + mockGetHTTPToolList.mockReset(); + + mockGetMCPChildren.mockImplementation( + async (app: { modules?: Array<{ toolConfig?: any }> }) => { + const toolSet = app.modules?.[0]?.toolConfig?.mcpToolSet; + return toolSet && Array.isArray(toolSet.toolList) ? toolSet.toolList : []; + } + ); + mockGetHTTPToolList.mockImplementation( + async (app: { modules?: Array<{ toolConfig?: any }> }) => { + const toolSet = app.modules?.[0]?.toolConfig?.httpToolSet; + return toolSet && Array.isArray(toolSet.toolList) ? toolSet.toolList : []; + } + ); + }); + const makeNode = ( nodeId: string, flowNodeType: string, @@ -951,7 +990,7 @@ describe('rewriteRuntimeWorkFlow', () => { const edges = [makeEdge('n1', 'n2')]; const originalNodesLen = nodes.length; const originalEdgesLen = edges.length; - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(nodes.length).toBe(originalNodesLen); expect(edges.length).toBe(originalEdgesLen); }); @@ -969,7 +1008,7 @@ describe('rewriteRuntimeWorkFlow', () => { const childNode = makeNode('child1', 'systemTool'); mockGetSystemToolRunTimeNodeFromSystemToolset.mockResolvedValue([childNode]); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(nodes.find((n) => n.nodeId === 'ts1')).toBeUndefined(); expect(nodes.find((n) => n.nodeId === 'child1')).toBeDefined(); @@ -983,7 +1022,7 @@ describe('rewriteRuntimeWorkFlow', () => { name: 'MCPTool', avatar: 'avatar.png', toolConfig: { - mcpToolSet: { toolId: 'mcp-tool-1' } + mcpToolSet: { toolId: 'mcp-app-1' } } } as any); const parentNode = makeNode('parent', FlowNodeTypeEnum.chatNode); @@ -1012,13 +1051,13 @@ describe('rewriteRuntimeWorkFlow', () => { { name: 'tool1', description: 'desc', inputSchema: {}, url: 'https://mcp.example.com' } ]); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(nodes.find((n) => n.nodeId === 'ts2')).toBeUndefined(); expect(nodes.find((n) => n.nodeId === 'ts20')).toMatchObject({ toolConfig: { - mcpToolSet: { - url: 'https://mcp.example.com' + mcpTool: { + toolId: 'mcp-mcp-app-1/tool1' } } }); @@ -1072,13 +1111,11 @@ describe('rewriteRuntimeWorkFlow', () => { } ]); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); const filteredEdges = filterOrphanEdges({ nodes, edges, workflowId: 'workflow-app' }); expect(nodes.find((n) => n.nodeId === 'ts20')?.jsonSchema).toEqual(fullSchema); - expect(nodes.find((n) => n.nodeId === 'ts20')?.toolConfig?.mcpToolSet).toMatchObject({ - url: 'https://mcp.example.com' - }); + expect(nodes.find((n) => n.nodeId === 'ts20')?.toolConfig).not.toHaveProperty('mcpToolSet'); expect(nodes.find((n) => n.nodeId === 'ts20')?.inputs[0]).toMatchObject({ key: 'city', selectedType: FlowNodeInputTypeEnum.agentGenerated, @@ -1106,72 +1143,75 @@ describe('rewriteRuntimeWorkFlow', () => { lean: vi.fn().mockResolvedValue(null) }); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(nodes.find((n) => n.nodeId === 'ts3')).toBeUndefined(); }); - it('should handle HTTP toolSet nodes', async () => { - const toolSetNode = makeNode('ts4', FlowNodeTypeEnum.toolSet, { - pluginId: 'http-plugin-1', - name: 'HTTPTool', - avatar: 'avatar.png', - toolConfig: { - httpToolSet: {} - } - } as any); - const parentNode = makeNode('parent', FlowNodeTypeEnum.chatNode); - const nodes = [parentNode, toolSetNode]; - const edges = [ - makeEdge('parent', 'ts4', { sourceHandle: 'out', targetHandle: 'selectedTools' }) - ]; - - mockMongoAppFindOne.mockReturnValue({ - lean: vi.fn().mockResolvedValue({ _id: 'http-plugin-1', name: 'HTTPApp' }) - }); - mockGetHTTPToolList.mockResolvedValue([ - { - name: 'api1', - description: 'desc1', - url: 'http://example.com/api1', - inputSchema: { - type: 'object', - properties: { - manual: { type: 'string', isToolParam: false }, - generated: { type: 'string', isToolParam: true } + it.each([undefined, '', 'http-plugin-1'])( + 'should handle HTTP toolSet nodes with legacy id %j', + async (toolId) => { + const toolSetNode = makeNode('ts4', FlowNodeTypeEnum.toolSet, { + pluginId: 'http-plugin-1', + name: 'HTTPTool', + avatar: 'avatar.png', + toolConfig: { + httpToolSet: { toolId } + } + } as any); + const parentNode = makeNode('parent', FlowNodeTypeEnum.chatNode); + const nodes = [parentNode, toolSetNode]; + const edges = [ + makeEdge('parent', 'ts4', { sourceHandle: 'out', targetHandle: 'selectedTools' }) + ]; + + mockMongoAppFindOne.mockReturnValue({ + lean: vi.fn().mockResolvedValue({ _id: 'http-plugin-1', name: 'HTTPApp' }) + }); + mockGetHTTPToolList.mockResolvedValue([ + { + name: 'api1', + description: 'desc1', + url: 'http://example.com/api1', + inputSchema: { + type: 'object', + properties: { + manual: { type: 'string', isToolParam: false }, + generated: { type: 'string', isToolParam: true } + }, + required: ['manual', 'generated'] }, - required: ['manual', 'generated'] + requestSchema: { + type: 'object', + properties: { + manual: { type: 'string', isToolParam: true }, + generated: { type: 'string', isToolParam: true } + }, + required: ['manual', 'generated'] + } }, - requestSchema: { - type: 'object', - properties: { - manual: { type: 'string', isToolParam: true }, - generated: { type: 'string', isToolParam: true } - }, - required: ['manual', 'generated'] - } - }, - { name: 'api2', description: 'desc2', url: 'http://example.com/api2' } - ]); - - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); - - expect(nodes.find((n) => n.nodeId === 'ts4')).toBeUndefined(); - expect(nodes.find((n) => n.nodeId === 'ts40')?.inputs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'manual', - selectedType: FlowNodeInputTypeEnum.input - }), - expect.objectContaining({ - key: 'generated', - selectedType: FlowNodeInputTypeEnum.agentGenerated - }) - ]) - ); - expect(nodes.find((n) => n.nodeId === 'ts41')).toBeDefined(); - expect(edges.filter((e) => e.target === 'ts40' || e.target === 'ts41').length).toBe(2); - }); + { name: 'api2', description: 'desc2', url: 'http://example.com/api2' } + ]); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); + + expect(nodes.find((n) => n.nodeId === 'ts4')).toBeUndefined(); + expect(nodes.find((n) => n.nodeId === 'ts40')?.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'manual', + selectedType: FlowNodeInputTypeEnum.input + }), + expect.objectContaining({ + key: 'generated', + selectedType: FlowNodeInputTypeEnum.agentGenerated + }) + ]) + ); + expect(nodes.find((n) => n.nodeId === 'ts41')).toBeDefined(); + expect(edges.filter((e) => e.target === 'ts40' || e.target === 'ts41').length).toBe(2); + } + ); // Helper: route MongoApp.find responses by the toolsetId it queries, since // parseMcpTool and parseHttpTool may both hit MongoApp.find in parallel. @@ -1227,7 +1267,7 @@ describe('rewriteRuntimeWorkFlow', () => { } }); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(mcpToolNode.jsonSchema).toEqual(toolAInputSchema); expect(mcpToolNode.intro).toBe('tool A description'); @@ -1293,7 +1333,7 @@ describe('rewriteRuntimeWorkFlow', () => { } }); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(httpToolNode.jsonSchema).toEqual(toolBRequestSchema); expect(httpToolNode.intro).toBe('tool B description'); @@ -1368,6 +1408,7 @@ describe('rewriteRuntimeWorkFlow', () => { await rewriteRuntimeWorkFlow({ teamId: 'team1', + tmbId: 'tmb1', nodes: [mcpToolNode, httpToolNode], edges: [] }); @@ -1409,7 +1450,12 @@ describe('rewriteRuntimeWorkFlow', () => { } }); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes: [httpToolNode], edges: [] }); + await rewriteRuntimeWorkFlow({ + teamId: 'team1', + tmbId: 'tmb1', + nodes: [httpToolNode], + edges: [] + }); expect(httpToolNode.jsonSchema).toEqual(inputSchema); }); @@ -1444,7 +1490,7 @@ describe('rewriteRuntimeWorkFlow', () => { } }); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(httpToolNode.jsonSchema).toEqual({ type: 'object' }); expect(httpToolNode.intro).toBe('nested tool'); @@ -1463,7 +1509,7 @@ describe('rewriteRuntimeWorkFlow', () => { setupFindByIdMap({}); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(httpToolNode.jsonSchema).toEqual({ type: 'original' }); expect(httpToolNode.intro).toBe('original'); @@ -1482,7 +1528,7 @@ describe('rewriteRuntimeWorkFlow', () => { setupFindByIdMap({}); - await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + await rewriteRuntimeWorkFlow({ teamId: 'team1', tmbId: 'tmb1', nodes, edges }); expect(httpToolNode.jsonSchema).toEqual({ type: 'original' }); expect(httpToolNode.intro).toBe('original'); diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx index 8e84e6d36780..9aaae0d17e5c 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx @@ -187,8 +187,12 @@ export const useSkillManager = ({ ); /* ===== Team agents/tools ===== */ + const parentTypes = useRef(new Map()); const { data: allTeamApps = [] } = useRequest(() => getTeamAppTemplates({ parentId: null }), { - manual: false + manual: false, + onSuccess(list) { + list.forEach(({ id, appType }) => parentTypes.current.set(id, appType)); + } }); const myTools = useMemo( () => @@ -218,11 +222,15 @@ export const useSkillManager = ({ ); const onFolderLoadTeamApps = useCallback(async (folderId: string, types: AppTypeEnum[]) => { - const children = await getTeamAppTemplates({ parentId: folderId, type: types }); - + const children = await getTeamAppTemplates({ + parentId: folderId, + parentType: parentTypes.current.get(folderId), + type: types + }); if (!children || children.length === 0) { return []; } + children.forEach(({ id, appType }) => parentTypes.current.set(id, appType)); return children.map((item) => { return { diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx index 48edc12018fe..2151fc203cf3 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import { useTranslation } from 'next-i18next'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; @@ -77,6 +77,7 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) const [templateType, setTemplateType] = useState(TemplateTypeEnum.systemTools); const [parentId, setParentId] = useState(''); const [parentSource, setParentSource] = useState(); + const parentTypes = useRef(new Map()); const [searchKey, setSearchKey] = useState(''); const [selectedTagIds, setSelectedTagIds] = useState([]); @@ -105,6 +106,7 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) } else if (type === TemplateTypeEnum.myTools) { return getTeamAppTemplates({ parentId, + parentType: parentId ? parentTypes.current.get(parentId) : undefined, searchKey: searchVal, type: [ AppTypeEnum.toolFolder, @@ -122,7 +124,13 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) } }, { - onSuccess(_, [{ type = templateType, parentId = '', source }]) { + onSuccess( + list: (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined, + [{ type = templateType, parentId = '', source }] + ) { + list?.forEach(({ id, appType }) => { + if (appType) parentTypes.current.set(id, appType); + }); setTemplateType(type); setParentId(parentId); setParentSource(parentId ? (source ?? parentSource) : undefined); diff --git a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/Edit.tsx b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/Edit.tsx index f5364b4261bd..13889ea0b770 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/Edit.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/Edit.tsx @@ -19,7 +19,8 @@ const Edit = () => { const toolSetNode = appDetail.modules.find( (item) => item.flowNodeType === FlowNodeTypeEnum.toolSet ); - return toolSetNode?.toolConfig?.httpToolSet; + const toolSet = toolSetNode?.toolConfig?.httpToolSet; + return toolSet && !('toolId' in toolSet) ? toolSet : undefined; }, [appDetail.modules]); const [currentTool, setCurrentTool] = useState( diff --git a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ManualToolModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ManualToolModal.tsx index b854f58cd1b9..e6622fa830ac 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ManualToolModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ManualToolModal.tsx @@ -197,7 +197,8 @@ const ManualToolModal = ({ const toolSetNode = appDetail.modules.find( (item) => item.flowNodeType === FlowNodeTypeEnum.toolSet ); - const existingToolList = toolSetNode?.toolConfig?.httpToolSet?.toolList || []; + const toolSet = toolSetNode?.toolConfig?.httpToolSet; + const existingToolList = toolSet && !('toolId' in toolSet) ? toolSet.toolList : []; const updatedToolList = (() => { if (isEditMode) { diff --git a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/SchemaConfigModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/SchemaConfigModal.tsx index 819cb987fbc4..79c49e718094 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/SchemaConfigModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/SchemaConfigModal.tsx @@ -60,7 +60,8 @@ const SchemaConfigModal = ({ onClose }: { onClose: () => void }) => { const toolSetNode = appDetail.modules.find( (item) => item.flowNodeType === FlowNodeTypeEnum.toolSet ); - return toolSetNode?.toolConfig?.httpToolSet; + const toolSet = toolSetNode?.toolConfig?.httpToolSet; + return toolSet && !('toolId' in toolSet) ? toolSet : undefined; }, [appDetail.modules]); const { register, setValue, handleSubmit, watch } = useForm({ diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index b5619f99e3e5..42d82d69a3ff 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -25,6 +25,8 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => const [parentId, setParentId] = useState(''); const [parentSource, setParentSource] = useState(); + // 进入目录/面包屑只携带 ID,保留已加载父项类型,避免再次查询详情。 + const parentTypes = useRef(new Map()); const appId = useContextSelector(AppContext, (v) => v.appDetail._id); const { basicNodeTemplates, getNodeList, nodeAmount } = useContextSelector( @@ -94,6 +96,7 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => // app, workflow-plugin, mcp return getTeamAppTemplates({ parentId, + parentType: parentId ? parentTypes.current.get(parentId) : undefined, searchKey: searchVal, type: [ AppTypeEnum.toolFolder, @@ -121,7 +124,10 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => } }, { - onSuccess() { + onSuccess(list: (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined) { + list?.forEach(({ id, appType }) => { + if (appType) parentTypes.current.set(id, appType); + }); searchKeyLock.current = false; } } diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/components/ToolSetList.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/components/ToolSetList.tsx index b0f0f4bd9104..06da54a0a3c5 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/components/ToolSetList.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/components/ToolSetList.tsx @@ -9,9 +9,11 @@ type ToolSetListItemType = { }; export const getNodeToolSetList = (tool: Pick) => { + const mcpToolSet = tool.toolConfig?.mcpToolSet; + const httpToolSet = tool.toolConfig?.httpToolSet; const toolList = - tool.toolConfig?.mcpToolSet?.toolList ?? - tool.toolConfig?.httpToolSet?.toolList ?? + (mcpToolSet && 'toolList' in mcpToolSet ? mcpToolSet.toolList : undefined) ?? + (httpToolSet && 'toolList' in httpToolSet ? httpToolSet.toolList : undefined) ?? tool.toolConfig?.systemToolSet?.toolList; return toolList ?? []; diff --git a/projects/app/src/pages/api/core/app/tool/getToolSetChildren.ts b/projects/app/src/pages/api/core/app/tool/getToolSetChildren.ts new file mode 100644 index 000000000000..f80206a25e9b --- /dev/null +++ b/projects/app/src/pages/api/core/app/tool/getToolSetChildren.ts @@ -0,0 +1,37 @@ +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { getMCPChildren } from '@fastgpt/service/core/app/mcp'; +import { getHTTPToolList } from '@fastgpt/service/core/app/http'; +import { + GetToolSetChildrenQuerySchema, + GetToolSetChildrenResponseSchema, + type GetToolSetChildrenQueryType, + type GetToolSetChildrenResponseType +} from '@fastgpt/global/openapi/core/app/tool/api'; + +/** 使用父资源读权限加载当前子工具,并在响应边界仅投影展示字段;普通目录不返回 App 配置。 */ +async function handler( + req: ApiRequestProps, GetToolSetChildrenQueryType> +): Promise { + const { appId, searchKey } = parseApiInput({ + req, + querySchema: GetToolSetChildrenQuerySchema + }).query; + const { app } = await authApp({ req, authToken: true, appId, per: ReadPermissionVal }); + const tools = await (async () => { + if (app.type === AppTypeEnum.mcpToolSet) return getMCPChildren(app); + if (app.type === AppTypeEnum.httpToolSet) return getHTTPToolList(app); + return []; + })(); + const keyword = searchKey?.trim().toLowerCase(); + return GetToolSetChildrenResponseSchema.parse({ + type: app.type, + tools: tools.filter((tool) => !keyword || tool.name.toLowerCase().includes(keyword)) + }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/web/core/app/api/tool.ts b/projects/app/src/web/core/app/api/tool.ts index ff088c7ed7e6..43185dd0ec78 100644 --- a/projects/app/src/web/core/app/api/tool.ts +++ b/projects/app/src/web/core/app/api/tool.ts @@ -3,7 +3,7 @@ import type { FlowNodeTemplateType, NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; -import { getAppDetailById, getMyApps } from '../api'; +import { getMyApps } from '../api'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; @@ -11,52 +11,48 @@ import type { ParentIdType, ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; -import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants'; -import { getMcpChildren } from './mcpTools'; import type { GetPreviewNodeQuery, + GetToolSetChildrenResponseType, GetSystemToolTemplatesBodyType, GetToolPathQueryType } from '@fastgpt/global/openapi/core/app/tool/api'; /* ============ team plugin ============== */ +/** parentType 来自已加载的列表项;普通目录和 Agent 查询直接走 list,不额外探测父资源。 */ export const getTeamAppTemplates = async (data?: { parentId?: ParentIdType; + parentType?: AppTypeEnum; searchKey?: string; type?: AppTypeEnum[]; }) => { - if (data?.parentId) { - // handle get mcptools - const app = await getAppDetailById(data.parentId); - if (app.type === AppTypeEnum.mcpToolSet) { - const children = await getMcpChildren({ id: data.parentId, searchKey: data.searchKey }); - return children.map((item) => ({ + const { parentType, ...listQuery } = data ?? {}; + // 列表项已经提供 appType;普通目录和 Agent 不再请求接口探测父资源类型。 + if ( + data?.parentId && + (parentType === AppTypeEnum.mcpToolSet || parentType === AppTypeEnum.httpToolSet) && + (!data.type || data.type.includes(parentType)) + ) { + const { type, tools } = await GET( + '/core/app/tool/getToolSetChildren', + { + appId: data.parentId, + searchKey: data.searchKey + } + ); + if (type === AppTypeEnum.mcpToolSet || type === AppTypeEnum.httpToolSet) { + return tools.map((item) => ({ ...item, intro: item.description || '', flowNodeType: FlowNodeTypeEnum.tool, templateType: FlowNodeTemplateTypeEnum.teamApp, - appType: app.type, - isTool: true, - isFolder: false - })); - // handle http toolset - } else if (app.type === AppTypeEnum.httpToolSet) { - const toolList = app.modules[0]?.toolConfig?.httpToolSet?.toolList; - if (!toolList) return []; - return toolList.map((item) => ({ - id: `${AppToolSourceEnum.http}-${app._id}/${item.name}`, - avatar: app.avatar, - name: item.name, - intro: item.description || '', - flowNodeType: FlowNodeTypeEnum.tool, - templateType: FlowNodeTemplateTypeEnum.teamApp, - appType: app.type, + appType: type, isTool: true, isFolder: false })); } } - return getMyApps(data).then((res) => + return getMyApps(data ? listQuery : undefined).then((res) => res.map((app) => ({ tmbId: app.tmbId, id: app._id, diff --git a/projects/app/test/api/core/app/tool/debugCompatibility.test.ts b/projects/app/test/api/core/app/tool/debugCompatibility.test.ts new file mode 100644 index 000000000000..0297f92adabb --- /dev/null +++ b/projects/app/test/api/core/app/tool/debugCompatibility.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Call } from '@test/utils/request'; +import { getUser } from '@test/datas/users'; +import { ContentTypes } from '@fastgpt/global/core/workflow/constants'; + +const mocks = vi.hoisted(() => ({ + runHTTPTool: vi.fn(), + toolCall: vi.fn(), + getTools: vi.fn(), + constructor: vi.fn() +})); +vi.mock('@fastgpt/service/core/app/http', async (importOriginal) => ({ + ...(await importOriginal()), + runHTTPTool: mocks.runHTTPTool +})); +vi.mock('@fastgpt/service/core/app/mcp', async (importOriginal) => ({ + ...(await importOriginal()), + MCPClient: vi.fn(function (options) { + mocks.constructor(options); + return { toolCall: mocks.toolCall, getTools: mocks.getTools }; + }) +})); +import runHTTP from '@/pages/api/core/app/httpTools/runTool'; +import runMCP from '@/pages/api/core/app/mcpTools/runTool'; +import getMCPTools from '@/pages/api/core/app/mcpTools/getTools'; + +describe('standalone toolset debug API compatibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps HTTP draft execution configuration and business values intact', async () => { + const auth = await getUser('http-debug'); + const body = { + baseUrl: 'https://203.0.113.10', + toolPath: '/unsaved-draft', + method: 'PUT', + params: { query: 'debug', payload: { requestSchema: 'business-data' } }, + headerSecret: { Authorization: { value: 'fake-debug-token' } }, + customHeaders: { 'X-Number': 3 }, + staticParams: [{ key: 'fixed', value: 'value' }], + staticHeaders: [{ key: 'X-Fixed', value: 'header' }], + staticBody: { type: ContentTypes.json, content: '{"query":"{{query}}"}' } + }; + mocks.runHTTPTool.mockResolvedValue({ data: { ok: true } }); + const result = await Call(runHTTP, { auth, body }); + expect(result.code).toBe(200); + expect(result.data).toEqual({ data: { ok: true } }); + expect(mocks.runHTTPTool).toHaveBeenCalledWith({ ...body, customHeaders: { 'X-Number': '3' } }); + }); + + it('retains complete MCP schemas when refreshing tools for the editor', async () => { + const auth = await getUser('mcp-debug-refresh'); + const tool = { + name: 'search', + description: 'Search', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', enum: ['debug', 'production'] }, + count: { type: 'number', minimum: 1 } + }, + required: ['query'] + } + }; + mocks.getTools.mockResolvedValue([tool]); + const result = await Call(getMCPTools, { + auth, + body: { + url: 'https://203.0.113.10/mcp', + headerSecret: { Authorization: { value: 'fake-debug-token' } } + } + }); + expect(result.code).toBe(200); + expect(result.data).toEqual([tool]); + expect(mocks.constructor).toHaveBeenCalledWith({ + url: 'https://203.0.113.10/mcp', + headers: { Authorization: 'fake-debug-token' } + }); + }); + + it('runs unsaved MCP parameters using the editor URL and headers without requiring a toolId', async () => { + const auth = await getUser('mcp-debug-run'); + const params = { query: 'debug', payload: { inputSchema: 'business-data' } }; + mocks.toolCall.mockResolvedValue({ content: [{ type: 'text', text: 'debug response' }] }); + const result = await Call(runMCP, { + auth, + body: { + url: 'https://203.0.113.10/mcp', + toolName: 'unsaved-tool', + params, + headerSecret: { Authorization: { value: 'fake-debug-token' } } + } + }); + expect(result.code).toBe(200); + expect(mocks.toolCall).toHaveBeenCalledWith({ toolName: 'unsaved-tool', params }); + expect(mocks.constructor).toHaveBeenCalledWith({ + url: 'https://203.0.113.10/mcp', + headers: { Authorization: 'fake-debug-token' } + }); + expect(result.data).toEqual({ content: [{ type: 'text', text: 'debug response' }] }); + }); +}); diff --git a/projects/app/test/api/core/app/tool/getToolSetChildren.test.ts b/projects/app/test/api/core/app/tool/getToolSetChildren.test.ts new file mode 100644 index 000000000000..53ad09bfc1e6 --- /dev/null +++ b/projects/app/test/api/core/app/tool/getToolSetChildren.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Call } from '@test/utils/request'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { GetMcpChildrenResponseSchema } from '@fastgpt/global/openapi/core/app/mcpTools/api'; + +const mocks = vi.hoisted(() => ({ authApp: vi.fn() })); +vi.mock('@fastgpt/service/support/permission/app/auth', () => ({ authApp: mocks.authApp })); +import handler from '@/pages/api/core/app/tool/getToolSetChildren'; + +const appId = '507f1f77bcf86cd799439011'; +const createToolset = (type: AppTypeEnum) => ({ + _id: appId, + type, + avatar: 'tools.svg', + modules: [ + { + toolConfig: { + [type === AppTypeEnum.mcpToolSet ? 'mcpToolSet' : 'httpToolSet']: { + url: 'https://mcp.example.com', + baseUrl: 'https://http.example.com', + apiSchemaStr: 'raw OpenAPI', + toolList: [ + { + name: 'Search[1]', + description: 'Search', + path: '/private', + method: 'POST', + staticHeaders: [{ key: 'X-Private', value: 'private-config' }], + staticParams: [{ key: 'token', value: 'private-config' }], + inputSchema: JSON.stringify({ + type: 'object', + properties: { privateField: { type: 'string' } } + }), + requestSchema: JSON.stringify({ type: 'object' }), + outputSchema: JSON.stringify({ type: 'object' }) + } + ] + } + } + } + ] +}); + +describe('getToolSetChildren', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([AppTypeEnum.mcpToolSet, AppTypeEnum.httpToolSet])( + 'returns only display fields from encoded %s definitions', + async (type) => { + const app = createToolset(type); + const original = structuredClone(app); + mocks.authApp.mockResolvedValueOnce({ app }); + const result = await Call(handler, { query: { appId, searchKey: ' search[1] ' } }); + expect(result.code).toBe(200); + expect(result.data).toEqual({ + type, + tools: [ + { + id: `${type === AppTypeEnum.mcpToolSet ? 'mcp' : 'http'}-${appId}/Search[1]`, + name: 'Search[1]', + description: 'Search', + avatar: 'tools.svg' + } + ] + }); + expect(mocks.authApp).toHaveBeenCalledWith( + expect.objectContaining({ appId, per: ReadPermissionVal, authToken: true }) + ); + expect(app).toEqual(original); + } + ); + + it.each([undefined, '', ' ', 'missing'])( + 'handles empty and nonmatching searches: %j', + async (searchKey) => { + mocks.authApp.mockResolvedValueOnce({ app: createToolset(AppTypeEnum.httpToolSet) }); + const result = await Call(handler, { query: { appId, searchKey } }); + expect(result.code).toBe(200); + expect(result.data.tools).toHaveLength(searchKey === 'missing' ? 0 : 1); + } + ); + + it('returns only resource type for a normal folder', async () => { + mocks.authApp.mockResolvedValueOnce({ + app: { ...createToolset(AppTypeEnum.httpToolSet), type: AppTypeEnum.toolFolder } + }); + const result = await Call(handler, { query: { appId } }); + expect(result.data).toEqual({ type: AppTypeEnum.toolFolder, tools: [] }); + }); + + it('rejects invalid ids before accessing resources', async () => { + const result = await Call(handler, { query: { appId: 'invalid' } }); + expect(result.code).not.toBe(200); + expect(mocks.authApp).not.toHaveBeenCalled(); + }); + + it('does not return definitions when authorization fails', async () => { + mocks.authApp.mockRejectedValueOnce(new Error('unAuthApp')); + const result = await Call(handler, { query: { appId } }); + expect(result.code).not.toBe(200); + expect(result.data?.tools).toBeUndefined(); + }); + + it('keeps the existing MCP children API schema contract unchanged', () => { + expect( + GetMcpChildrenResponseSchema.parse([ + { + id: `mcp-${appId}/search`, + name: 'search', + description: 'Search', + avatar: '', + inputSchema: { type: 'object' }, + url: 'https://private.example.com', + headerSecret: { token: 'secret' } + } + ]) + ).toEqual([ + { + id: `mcp-${appId}/search`, + name: 'search', + description: 'Search', + avatar: '', + inputSchema: { type: 'object' } + } + ]); + }); +}); diff --git a/projects/app/test/api/core/app/tool/legacyMcpCompatibility.test.ts b/projects/app/test/api/core/app/tool/legacyMcpCompatibility.test.ts new file mode 100644 index 000000000000..2d5654470e7b --- /dev/null +++ b/projects/app/test/api/core/app/tool/legacyMcpCompatibility.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getUser } from '@test/datas/users'; +import { Call as callAPI } from '@test/utils/request'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { + FlowNodeTypeEnum, + FlowNodeInputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { storeSecretValue } from '@fastgpt/service/common/secret/utils'; +import { storeNodes2RuntimeNodes } from '@fastgpt/global/core/workflow/runtime/utils'; +import { rewriteRuntimeWorkFlow } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { dispatchRunTool } from '@fastgpt/service/core/workflow/dispatch/child/runTool'; +import { dispatchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool'; +import preview from '@/pages/api/core/app/tool/getPreviewNode'; +import createApp from '@/pages/api/core/app/create'; + +const mocks = vi.hoisted(() => ({ clientOptions: vi.fn(), call: vi.fn() })); +vi.mock('@fastgpt/service/core/app/mcp', async (importOriginal) => ({ + ...(await importOriginal()), + MCPClient: vi.fn(function (options) { + mocks.clientOptions(options); + return { toolCall: mocks.call }; + }) +})); +vi.mock('@fastgpt/service/core/workflow/utils/context', () => ({ + getWorkflowContext: () => ({ mcpClientMemory: {} }) +})); +const Call: typeof callAPI = (handler, props) => callAPI(handler, { headers: {}, ...props }); + +describe('legacy MCP resource compatibility without migration', () => { + it.each(['children-map', 'children-single', 'inline-empty-id'] as const)( + 'adds, stores and executes %s resources without changing historical records', + async (generation) => { + vi.clearAllMocks(); + mocks.call.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + const auth = await getUser(`legacy-mcp-${generation}`); + const url = 'https://203.0.113.10/mcp'; + const headers = storeSecretValue({ + Authorization: { value: 'legacy-token' }, + 'X-Key': { value: 'api-key' } + }); + const childHeaders = generation === 'children-single' ? headers.Authorization : headers; + const tool = { + name: 'search', + description: 'Search', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Query', pattern: '^allowed$' } }, + required: ['query'] + } + }; + const sourceNode = { + nodeId: 'legacy-set', + name: 'Legacy MCP', + flowNodeType: FlowNodeTypeEnum.toolSet, + outputs: [], + inputs: + generation === 'inline-empty-id' + ? [] + : [ + { + key: NodeInputKeyEnum.toolSetData, + label: 'Tool Set Data', + renderTypeList: [FlowNodeInputTypeEnum.hidden], + value: { url, headerSecret: headers, toolList: [tool] } + } + ], + ...(generation === 'inline-empty-id' + ? { + toolConfig: { + mcpToolSet: { toolId: '', url, headerSecret: headers, toolList: [tool] } + } + } + : {}) + }; + const parent = await MongoApp.create({ + name: 'Legacy MCP', + avatar: 'mcp.svg', + teamId: auth.teamId, + tmbId: auth.tmbId, + type: AppTypeEnum.mcpToolSet, + modules: [sourceNode], + edges: [], + version: 'v2' + }); + const sourceIds = [parent._id]; + if (generation !== 'inline-empty-id') { + const child = await MongoApp.create({ + name: 'search', + avatar: 'mcp.svg', + teamId: auth.teamId, + tmbId: auth.tmbId, + parentId: parent._id, + type: AppTypeEnum.tool, + version: 'v2', + modules: [ + { + nodeId: 'legacy-child', + name: 'Search', + flowNodeType: FlowNodeTypeEnum.tool, + outputs: [], + inputs: [ + { + key: NodeInputKeyEnum.toolData, + label: 'Tool Data', + renderTypeList: [FlowNodeInputTypeEnum.hidden], + value: { ...tool, url, headerSecret: childHeaders } + } + ] + } + ] + }); + sourceIds.push(child._id); + } + const sourceQuery = { _id: { $in: sourceIds } }; + const sourceBefore = await MongoApp.find(sourceQuery).sort({ _id: 1 }).lean(); + const sourceId = String(parent._id); + const toolsetPreview = await Call(preview, { + auth, + query: { appId: sourceId, versionId: '' } + }); + const childPreview = await Call(preview, { + auth, + query: { appId: `mcp-${sourceId}/search`, versionId: '' } + }); + expect(toolsetPreview.error).toBeUndefined(); + expect(childPreview.error).toBeUndefined(); + expect(toolsetPreview.data.inputs).toEqual([]); + expect(toolsetPreview.data.toolConfig.mcpToolSet.toolId).toBe(sourceId); + expect(childPreview.data.inputs.map((input: any) => input.key)).toEqual(['query']); + for (const node of [toolsetPreview.data, childPreview.data]) { + expect(JSON.stringify(node)).not.toContain('inputSchema'); + expect(JSON.stringify(node)).not.toContain('customJsonSchema'); + expect(JSON.stringify(node)).not.toContain('headerSecret'); + } + const created = await Call(createApp, { + auth, + body: { + name: 'Workflow', + type: AppTypeEnum.workflow, + modules: [ + { ...toolsetPreview.data, nodeId: 'set-node' }, + { ...childPreview.data, nodeId: 'single-node' } + ], + edges: [] + } + }); + expect(created.error).toBeUndefined(); + const stored = await MongoApp.findById(String(created.data)).lean(); + expect(JSON.stringify(stored!.modules)).not.toContain('inputSchema'); + const nodes = storeNodes2RuntimeNodes(stored!.modules, ['set-node', 'single-node']); + await rewriteRuntimeWorkFlow({ teamId: auth.teamId, tmbId: auth.tmbId, nodes, edges: [] }); + expect(nodes).toHaveLength(2); + nodes.forEach((node) => + expect(node.jsonSchema?.properties?.query?.pattern).toBe('^allowed$') + ); + if (generation === 'inline-empty-id') { + const legacyRuntimeNodes = storeNodes2RuntimeNodes( + [{ ...sourceNode, pluginId: sourceId }], + ['legacy-set'] + ); + await rewriteRuntimeWorkFlow({ + teamId: auth.teamId, + tmbId: auth.tmbId, + nodes: legacyRuntimeNodes, + edges: [] + }); + expect(legacyRuntimeNodes).toHaveLength(1); + expect(legacyRuntimeNodes[0].toolConfig?.mcpTool?.toolId).toBe(`mcp-${sourceId}/search`); + expect(legacyRuntimeNodes[0].jsonSchema?.properties?.query?.pattern).toBe('^allowed$'); + } + const runningAppInfo = { teamId: auth.teamId, tmbId: auth.tmbId, name: 'Workflow' }; + const runningUserInfo = { teamId: auth.teamId, tmbId: auth.tmbId }; + const result = await dispatchRunTool({ + node: nodes[0], + params: { query: 'allowed' }, + runningAppInfo, + runningUserInfo, + variableState: new Map(), + uid: auth.tmbId, + chatId: 'debug', + responseChatItemId: 'reply', + usagePush: vi.fn() + } as any); + expect(result.error).toBeUndefined(); + const agentResult = await dispatchTool({ + tool: { name: 'Search', avatar: '', toolConfig: nodes[0].toolConfig }, + params: { query: 'allowed' }, + runningAppInfo, + runningUserInfo, + variableState: new Map(), + uid: auth.tmbId, + chatId: 'debug' + } as any); + expect(agentResult.errorMessage).toBeUndefined(); + const expectedHeaders = + generation === 'children-single' + ? { Authorization: 'legacy-token' } + : { Authorization: 'legacy-token', 'X-Key': 'api-key' }; + expect(mocks.clientOptions).toHaveBeenCalledTimes(2); + mocks.clientOptions.mock.calls.forEach(([options]) => + expect(options).toEqual({ url, headers: expectedHeaders }) + ); + expect(mocks.call).toHaveBeenCalledTimes(2); + expect(await MongoApp.find(sourceQuery).sort({ _id: 1 }).lean()).toEqual(sourceBefore); + }, + 30000 + ); +}); diff --git a/projects/app/test/pageComponents/app/detail/Edit/toolsetDebug.test.ts b/projects/app/test/pageComponents/app/detail/Edit/toolsetDebug.test.ts new file mode 100644 index 000000000000..e29b158de79c --- /dev/null +++ b/projects/app/test/pageComponents/app/detail/Edit/toolsetDebug.test.ts @@ -0,0 +1,148 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContentTypes } from '@fastgpt/global/core/workflow/constants'; + +const mocks = vi.hoisted(() => ({ + run: undefined as unknown as (data: Record) => Promise, + fields: [] as { fieldName: string; required?: boolean }[], + postHTTP: vi.fn(), + postMCP: vi.fn() +})); +vi.mock('next-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('use-context-selector', () => ({ + useContextSelector: (_context: unknown, select: (value: any) => unknown) => + select({ appDetail: { _id: 'app-id' } }) +})); +vi.mock('@/pageComponents/app/detail/context', () => ({ AppContext: {} })); +vi.mock('@/pageComponents/app/detail/constants', () => ({ cardStyles: {} })); +vi.mock('@/web/core/chat/context/useChatStore', () => ({ + useChatStore: () => ({ chatId: 'debug' }) +})); +vi.mock('@/web/core/chat/context/chatItemContext', () => ({ + default: ({ children }: any) => children +})); +vi.mock('@/web/core/chat/context/chatRecordContext', () => ({ + default: ({ children }: any) => children +})); +vi.mock('react-hook-form', () => ({ + useForm: () => ({ handleSubmit: (callback: unknown) => callback, reset: vi.fn() }) +})); +vi.mock('@fastgpt/web/hooks/useRequest', () => ({ + useRequest: (callback: any) => { + mocks.run = callback; + return { runAsync: callback, loading: false }; + } +})); +vi.mock('@/web/core/app/api/httpTools', () => ({ postRunHTTPTool: mocks.postHTTP })); +vi.mock('@/web/core/app/api/mcpTools', () => ({ postRunMCPTool: mocks.postMCP })); +vi.mock('@/components/Markdown', () => ({ default: () => null })); +vi.mock('@fastgpt/web/components/common/Tabs/LightRowTabs', () => ({ default: () => null })); +vi.mock('@fastgpt/web/components/common/EmptyTip', () => ({ default: () => null })); +vi.mock('@fastgpt/web/components/common/MyBox/FormLabel', () => ({ + default: ({ children }: any) => children +})); +vi.mock('@/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/ValueTypeLabel', () => ({ + default: () => null +})); +vi.mock('@chakra-ui/react', async () => { + const { createElement } = await import('react'); + const Element = ({ children }: any) => createElement('div', null, children); + return { Box: Element, Flex: Element, Button: Element, Center: Element, HStack: Element }; +}); +vi.mock('@/components/core/app/formRender/LabelAndForm', async () => { + const { createElement } = await import('react'); + return { + default: (props: any) => { + mocks.fields.push(props); + return createElement('input', { name: props.fieldName, required: props.required }); + } + }; +}); +import HTTPDebug from '@/pageComponents/app/detail/Edit/HTTPTools/ChatTest'; +import MCPDebug from '@/pageComponents/app/detail/Edit/MCPTools/ChatTest'; + +describe('toolset debug page schema consumers', () => { + beforeEach(() => { + mocks.fields = []; + vi.clearAllMocks(); + }); + const inputSchema = { + type: 'object', + properties: { query: { type: 'string' }, count: { type: 'number' } }, + required: ['query'] + }; + + it('renders HTTP inputs and submits current draft path, headers and static body', async () => { + const tool = { + name: 'search', + description: 'Search', + path: '/draft', + method: 'PUT', + inputSchema, + staticHeaders: [{ key: 'X-Draft', value: 'true' }], + staticParams: [{ key: 'fixed', value: 'query' }], + staticBody: { type: ContentTypes.json, content: '{"draft":true}' } + }; + const html = renderToStaticMarkup( + React.createElement(HTTPDebug, { + currentTool: tool, + baseUrl: 'https://example.com', + headerSecret: { Authorization: { value: 'fake' } }, + customHeaders: { 'X-App': 'app' } + }) + ); + expect(html).toContain('name="query"'); + expect(html).toContain('name="count"'); + expect(mocks.fields.find((field) => field.fieldName === 'query')?.required).toBe(true); + await mocks.run({ query: 'debug' }); + expect(mocks.postHTTP).toHaveBeenCalledWith({ + baseUrl: 'https://example.com', + toolPath: '/draft', + method: 'PUT', + params: { query: 'debug' }, + headerSecret: { Authorization: { value: 'fake' } }, + customHeaders: { 'X-App': 'app' }, + staticHeaders: tool.staticHeaders, + staticParams: tool.staticParams, + staticBody: tool.staticBody + }); + }); + + it('renders MCP inputs and converts numeric input before submitting current draft', async () => { + const html = renderToStaticMarkup( + React.createElement(MCPDebug, { + currentTool: { name: 'search', description: 'Search', inputSchema }, + url: 'https://example.com/mcp', + headerSecret: { Authorization: { value: 'fake' } } + }) + ); + expect(html).toContain('name="query"'); + expect(html).toContain('name="count"'); + await mocks.run({ query: 'debug', count: '3' }); + expect(mocks.postMCP).toHaveBeenCalledWith({ + url: 'https://example.com/mcp', + toolName: 'search', + params: { query: 'debug', count: 3 }, + headerSecret: { Authorization: { value: 'fake' } } + }); + }); + + it.each(['mcp', 'http'])( + 'does not submit when the %s editor has no selected tool', + async (source) => { + renderToStaticMarkup( + source === 'mcp' + ? React.createElement(MCPDebug, { url: 'https://example.com/mcp', headerSecret: {} }) + : React.createElement(HTTPDebug, { + baseUrl: 'https://example.com', + headerSecret: {}, + customHeaders: {} + }) + ); + await mocks.run({}); + expect(mocks.postHTTP).not.toHaveBeenCalled(); + expect(mocks.postMCP).not.toHaveBeenCalled(); + } + ); +}); diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.test.ts new file mode 100644 index 000000000000..37b436ae00a3 --- /dev/null +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.test.ts @@ -0,0 +1,129 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; + +const mocks = vi.hoisted(() => ({ + getTeamAppTemplates: vi.fn(), + getAppToolTemplates: vi.fn(), + requests: [] as ((...args: any[]) => Promise)[] +})); +vi.mock('@/web/core/app/api/tool', () => ({ + getTeamAppTemplates: mocks.getTeamAppTemplates, + getAppToolTemplates: mocks.getAppToolTemplates +})); +vi.mock('@/web/core/plugin/toolTag/api', () => ({ getPluginToolTags: vi.fn() })); +vi.mock('@fastgpt/web/hooks/useRequest', () => ({ + useRequest: (request: (...args: any[]) => unknown, options: any = {}) => { + const runAsync = async (...args: any[]) => { + const result = await request(...args); + options.onSuccess?.(result, args); + return result; + }; + mocks.requests.push(runAsync); + return { runAsync, data: undefined, loading: false }; + } +})); +vi.mock('ahooks', () => ({ useDebounceEffect: vi.fn() })); +vi.mock('use-context-selector', () => ({ + useContextSelector: (context: any, selector: (value: any) => unknown) => selector(context.value) +})); +vi.mock('@/pageComponents/app/detail/context', () => ({ + AppContext: { value: { appDetail: { _id: 'current-app' } } } +})); +vi.mock('@/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext', () => ({ + WorkflowBufferDataContext: { + value: { basicNodeTemplates: [], getNodeList: () => [], nodeAmount: 0 } + } +})); +vi.mock( + '@/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/header', + () => ({ + TemplateTypeEnum: { + basic: 'basic', + systemTools: 'systemTools', + myTools: 'myTools', + agent: 'agent' + } + }) +); +import { useNodeTemplates } from '@/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates'; + +describe('useNodeTemplates parent type routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requests = []; + }); + const getLoader = () => { + const Component = () => { + useNodeTemplates(); + return null; + }; + renderToStaticMarkup(React.createElement(Component)); + return mocks.requests[2]; + }; + + it('retains loaded parent types across nested navigation, searches and breadcrumb returns', async () => { + const load = getLoader(); + mocks.getTeamAppTemplates + .mockResolvedValueOnce([{ id: 'folder', appType: AppTypeEnum.toolFolder }]) + .mockResolvedValueOnce([ + { id: 'mcp-set', appType: AppTypeEnum.mcpToolSet }, + { id: 'http-set', appType: AppTypeEnum.httpToolSet } + ]) + .mockResolvedValue([]); + await load({ type: 'myTools' }); + await load({ type: 'myTools', parentId: 'folder' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0]).toMatchObject({ + parentId: 'folder', + parentType: AppTypeEnum.toolFolder + }); + await load({ type: 'myTools', parentId: 'mcp-set', searchVal: 'search' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0]).toMatchObject({ + parentType: AppTypeEnum.mcpToolSet, + searchKey: 'search' + }); + await load({ type: 'myTools', parentId: 'http-set' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0]).toMatchObject({ + parentType: AppTypeEnum.httpToolSet + }); + await load({ type: 'myTools', parentId: 'folder' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0]).toMatchObject({ + parentType: AppTypeEnum.toolFolder + }); + await load({ type: 'myTools', parentId: '' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0].parentType).toBeUndefined(); + }); + + it('never passes a toolset parent type for the Agent tab', async () => { + const load = getLoader(); + mocks.getTeamAppTemplates + .mockResolvedValueOnce([{ id: 'parent', appType: AppTypeEnum.mcpToolSet }]) + .mockResolvedValue([]); + await load({ type: 'myTools' }); + await load({ type: 'agent', parentId: 'parent' }); + expect(mocks.getTeamAppTemplates.mock.lastCall?.[0]).toEqual({ + parentId: 'parent', + searchKey: undefined, + type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] + }); + }); + + it('keeps system tools on the existing template interface', async () => { + const load = getLoader(); + mocks.getAppToolTemplates.mockResolvedValue([]); + await load({ + type: 'systemTools', + parentId: 'system-set', + source: 'debug:tmbId:member', + tags: ['search'] + }); + expect(mocks.getAppToolTemplates).toHaveBeenCalledWith({ + parentId: 'system-set', + source: 'debug:tmbId:member', + searchKey: undefined, + tags: ['search'] + }); + expect(mocks.getTeamAppTemplates).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/web/core/app/tool/templates.test.ts b/projects/app/test/web/core/app/tool/templates.test.ts index ca4e9b59b222..73f03d3aac0c 100644 --- a/projects/app/test/web/core/app/tool/templates.test.ts +++ b/projects/app/test/web/core/app/tool/templates.test.ts @@ -4,21 +4,18 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; const mocks = vi.hoisted(() => ({ getAppDetailById: vi.fn(), - getMcpChildren: vi.fn() + GET: vi.fn(), + getMyApps: vi.fn() })); vi.mock('@/web/common/api/request', () => ({ - GET: vi.fn(), + GET: mocks.GET, POST: vi.fn() })); vi.mock('@/web/core/app/api', () => ({ getAppDetailById: mocks.getAppDetailById, - getMyApps: vi.fn() -})); - -vi.mock('@/web/core/app/api/mcpTools', () => ({ - getMcpChildren: mocks.getMcpChildren + getMyApps: mocks.getMyApps })); import { getTeamAppTemplates } from '@/web/core/app/api/tool'; @@ -29,41 +26,113 @@ describe('getTeamAppTemplates', () => { }); it('marks MCP and HTTP toolset children as selectable tools', async () => { - mocks.getAppDetailById.mockResolvedValueOnce({ + mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.mcpToolSet, - _id: 'mcp-set' + tools: [{ id: 'mcp-mcp-set/search', name: 'search', description: 'Search' }] }); - mocks.getMcpChildren.mockResolvedValueOnce([ - { id: 'mcp-set/search', name: 'search', description: 'Search' } - ]); - const mcpTemplates = await getTeamAppTemplates({ parentId: 'mcp-set' }); + const mcpTemplates = await getTeamAppTemplates({ + parentId: 'mcp-set', + parentType: AppTypeEnum.mcpToolSet + }); expect(mcpTemplates[0]).toMatchObject({ - id: 'mcp-set/search', + id: 'mcp-mcp-set/search', flowNodeType: FlowNodeTypeEnum.tool, isTool: true }); - mocks.getAppDetailById.mockResolvedValueOnce({ + mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.httpToolSet, - _id: 'http-set', - avatar: 'avatar', - modules: [ - { - toolConfig: { - httpToolSet: { - toolList: [{ name: 'create', description: 'Create' }] - } - } - } + tools: [ + { id: 'http-http-set/create', name: 'create', description: 'Create', avatar: 'avatar' } ] }); - const httpTemplates = await getTeamAppTemplates({ parentId: 'http-set' }); + const httpTemplates = await getTeamAppTemplates({ + parentId: 'http-set', + parentType: AppTypeEnum.httpToolSet + }); expect(httpTemplates[0]).toMatchObject({ id: 'http-http-set/create', flowNodeType: FlowNodeTypeEnum.tool, isTool: true }); + expect(mocks.GET).toHaveBeenNthCalledWith(1, '/core/app/tool/getToolSetChildren', { + appId: 'mcp-set', + searchKey: undefined + }); + expect(mocks.GET).toHaveBeenNthCalledWith(2, '/core/app/tool/getToolSetChildren', { + appId: 'http-set', + searchKey: undefined + }); + expect(mocks.getAppDetailById).not.toHaveBeenCalled(); + expect(mocks.getMyApps).not.toHaveBeenCalled(); + }); + + it('lists normal folders directly and preserves filters without sending parentType', async () => { + mocks.getMyApps.mockResolvedValueOnce([]); + const query = { + parentId: 'folder', + parentType: AppTypeEnum.toolFolder, + searchKey: 'search', + type: [AppTypeEnum.workflowTool] + }; + expect(await getTeamAppTemplates(query)).toEqual([]); + expect(mocks.getMyApps).toHaveBeenCalledExactlyOnceWith({ + parentId: 'folder', + searchKey: 'search', + type: [AppTypeEnum.workflowTool] + }); + expect(mocks.GET).not.toHaveBeenCalled(); + expect(mocks.getAppDetailById).not.toHaveBeenCalled(); + }); + + it.each([undefined, AppTypeEnum.folder, AppTypeEnum.mcpToolSet])( + 'keeps Agent lists on list regardless of cached parent type: %j', + async (parentType) => { + mocks.getMyApps.mockResolvedValueOnce([]); + const type = [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]; + expect(await getTeamAppTemplates({ parentId: 'agent-folder', parentType, type })).toEqual([]); + expect(mocks.getMyApps).toHaveBeenCalledExactlyOnceWith({ parentId: 'agent-folder', type }); + expect(mocks.GET).not.toHaveBeenCalled(); + } + ); + + it('preserves appType in root items so navigation can select its endpoint without detail', async () => { + mocks.getMyApps.mockResolvedValueOnce([ + { _id: 'mcp-set', type: AppTypeEnum.mcpToolSet, name: 'MCP', avatar: '', intro: '' } + ]); + const [parent] = await getTeamAppTemplates(); + mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.mcpToolSet, tools: [] }); + await getTeamAppTemplates({ parentId: parent.id, parentType: parent.appType }); + expect(mocks.getMyApps).toHaveBeenCalledTimes(1); + expect(mocks.GET).toHaveBeenCalledExactlyOnceWith('/core/app/tool/getToolSetChildren', { + appId: 'mcp-set', + searchKey: undefined + }); + expect(mocks.getAppDetailById).not.toHaveBeenCalled(); + }); + + it('loads root without fetching a parent resource', async () => { + mocks.getMyApps.mockResolvedValueOnce([]); + expect(await getTeamAppTemplates()).toEqual([]); + expect(mocks.GET).not.toHaveBeenCalled(); + }); + + it('does not fall back to folder listing for empty toolsets', async () => { + mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.httpToolSet, tools: [] }); + expect( + await getTeamAppTemplates({ parentId: 'empty', parentType: AppTypeEnum.httpToolSet }) + ).toEqual([]); + expect(mocks.getMyApps).not.toHaveBeenCalled(); + }); + + it('propagates denied access without requesting full details', async () => { + mocks.GET.mockRejectedValueOnce(new Error('unAuthApp')); + await expect( + getTeamAppTemplates({ parentId: 'denied', parentType: AppTypeEnum.mcpToolSet }) + ).rejects.toThrow('unAuthApp'); + expect(mocks.getMyApps).not.toHaveBeenCalled(); + expect(mocks.getAppDetailById).not.toHaveBeenCalled(); }); });