fix: http tool schema (#6768)

* fix: http tool schema

* perf: del dataset

* perf: review

* add test
This commit is contained in:
Archer
2026-04-18 20:47:39 +08:00
committed by GitHub
parent 7506a147e6
commit 025b3dacab
14 changed files with 771 additions and 49 deletions
@@ -9,12 +9,13 @@ description: 'FastGPT V4.14.10 更新说明'
以下针对的是 `docker compose` 部署方案的配置调整,使用`sealos`的商业版用户,可私信支持人员,提供在线的沙盒服务方案。
打开[最新 yml 部署文件](https://github.com/labring/FastGPT/blob/main/deploy/docker/cn/docker-compose.pg.yml),调整以下内容:
参考[最新 yml 部署文件](https://github.com/labring/FastGPT/blob/main/deploy/docker/cn/docker-compose.pg.yml),调整本地 yml 文件,加入以下内容:
1. 在文件顶部增加 `x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken'` 变量配置。
2. 增加 3 组 services: `opensandbox-server`,`volume-manager`,`agent-sandbox-image`
3. 增加 `configs`, 文件底部可找到该内容,直接复制添加
4. 修改 `fastgpt-app`/`fastgpt-pro` 环境变量, 增加以下变量:
2. 增加 5 组 services: `opensandbox-server`,`opensandbox-agent-sandbox-image`,`opensandbox-execd-image`,`opensandbox-egress-image`,`fastgpt-volume-manager`
3. 调整 `networks`,可参考最新的 yml 完全修改
4. 增加 `configs`配置, 文件底部可找到该内容,直接复制添加。
5. 修改 `fastgpt-app`/`fastgpt-pro` 环境变量, 增加以下变量:
```bash
# ==================== Agent sandbox 配置 ====================
@@ -25,6 +26,7 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY:
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: docker
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: v0.1
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: true
# Volume 持久化配置(opensandbox provider 下可选)
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://volume-manager:3000
@@ -39,7 +41,7 @@ AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
### 3. 更新镜像 tag
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.10.2
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.10.4
- 更新 fastpgt-pro(商业版) 镜像 tag: v4.14.10
- 更新 code-sandbox 镜像 tag: v4.14.10
- 更新 fastgpt-plugin 镜像 tag: v0.5.6
@@ -39,4 +39,4 @@ description: 'FastGPT V4.14.11 更新说明'
8. 工作流代码运行节点,AI 生成代码后,会讲输出值的 id 全部替换,优化成相同 key 的 id 不替换。
9. 工作流中,父级节点受到辅助线自动对齐时候,其子节点位置会偏移。
10. 评估列表权限过滤未覆盖继承权限。
11. MCP 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。
11. MCP 工具和 Http 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。
+10 -10
View File
@@ -73,8 +73,8 @@
"document/content/docs/introduction/guide/dashboard/workflow/laf.mdx": "2025-07-23T21:35:03+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/loop.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/loop.mdx": "2025-09-17T22:29:56+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/parallel_run.en.mdx": "2026-04-17T15:12:11+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/parallel_run.mdx": "2026-04-17T15:12:11+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/parallel_run.en.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/parallel_run.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/question_classify.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/question_classify.mdx": "2025-07-23T21:35:03+08:00",
"document/content/docs/introduction/guide/dashboard/workflow/reply.en.mdx": "2026-02-26T22:14:30+08:00",
@@ -155,8 +155,8 @@
"document/content/docs/self-host/config/model/minimax.mdx": "2026-03-19T09:32:57-05:00",
"document/content/docs/self-host/config/model/siliconCloud.en.mdx": "2026-03-19T14:09:03+08:00",
"document/content/docs/self-host/config/model/siliconCloud.mdx": "2026-03-19T14:09:03+08:00",
"document/content/docs/self-host/config/object-storage.en.mdx": "2026-04-13T17:52:30+08:00",
"document/content/docs/self-host/config/object-storage.mdx": "2026-04-13T17:52:30+08:00",
"document/content/docs/self-host/config/object-storage.en.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/self-host/config/object-storage.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/self-host/config/signoz.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/config/signoz.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/custom-models/bge-rerank.en.mdx": "2026-03-03T17:39:47+08:00",
@@ -175,8 +175,8 @@
"document/content/docs/self-host/custom-models/ollama.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/custom-models/xinference.en.mdx": "2026-03-30T10:05:42+08:00",
"document/content/docs/self-host/custom-models/xinference.mdx": "2026-03-30T10:05:42+08:00",
"document/content/docs/self-host/deploy/docker.en.mdx": "2026-04-16T15:22:51+08:00",
"document/content/docs/self-host/deploy/docker.mdx": "2026-04-16T15:22:51+08:00",
"document/content/docs/self-host/deploy/docker.en.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/self-host/deploy/docker.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/self-host/deploy/sealos.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/deploy/sealos.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/design/dataset.en.mdx": "2026-03-03T17:39:47+08:00",
@@ -223,8 +223,8 @@
"document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.en.mdx": "2026-03-31T23:15:29+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-04-08T16:15:25+08:00",
"document/content/docs/self-host/upgrading/4-14/41411.mdx": "2026-04-17T17:46:20+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-04-18T19:42:07+08:00",
"document/content/docs/self-host/upgrading/4-14/41411.mdx": "2026-04-18T19:42:07+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
@@ -385,8 +385,8 @@
"document/content/docs/self-host/upgrading/outdated/499.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/toc.en.mdx": "2026-04-17T15:12:11+08:00",
"document/content/docs/toc.mdx": "2026-04-17T15:12:11+08:00",
"document/content/docs/toc.en.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/toc.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00",
"document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00",
@@ -8,6 +8,7 @@ import { type StoreSecretValueType } from '../../../../common/secret/type';
import { type JsonSchemaPropertiesItemType } from '../../jsonschema';
import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../workflow/constants';
import { i18nT } from '../../../../../web/i18n/utils';
import type { NodeToolConfigType } from '../../../workflow/type/node';
export const getHTTPToolSetRuntimeNode = ({
name,
@@ -89,6 +90,25 @@ export const getHTTPToolRuntimeNode = ({
};
};
export const parseHttpToolConfig = (
config: NonNullable<NodeToolConfigType['httpTool']>
):
| {
toolsetId: string;
toolName: string;
}
| undefined => {
const prefix = `${AppToolSourceEnum.http}-`;
if (!config.toolId.startsWith(prefix)) return undefined;
const [toolsetId, ...rest] = config.toolId.slice(prefix.length).split('/');
const toolName = rest.join('/');
if (!toolsetId || !toolName) return undefined;
return {
toolsetId,
toolName
};
};
export const pathData2ToolList = async (
pathData: PathDataType[]
): Promise<HttpToolConfigType[]> => {
@@ -92,7 +92,8 @@ export const parsetMcpToolConfig = (
| undefined => {
const prefix = `${AppToolSourceEnum.mcp}-`;
if (!config.toolId.startsWith(prefix)) return undefined;
const [toolsetId, toolName] = config.toolId.slice(prefix.length).split('/');
const [toolsetId, ...rest] = config.toolId.slice(prefix.length).split('/');
const toolName = rest.join('/');
if (!toolsetId || !toolName) return undefined;
return {
toolsetId,
@@ -0,0 +1,14 @@
import type { AppSchemaType } from '@fastgpt/global/core/app/type';
import { MongoApp } from '../../schema';
export const getHttpToolsets = ({
teamId,
ids,
field
}: {
teamId: string;
ids: string[];
field?: Record<string, boolean>;
}): Promise<AppSchemaType[]> => {
return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean();
};
@@ -1,3 +1,4 @@
import type { AppSchemaType } from '@fastgpt/global/core/app/type';
import { MongoApp } from '../../schema';
export const getMcpToolsets = ({
@@ -8,6 +9,6 @@ export const getMcpToolsets = ({
teamId: string;
ids: string[];
field?: Record<string, boolean>;
}) => {
}): Promise<AppSchemaType[]> => {
return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean();
};
@@ -43,25 +43,32 @@ export const deleteTeamAllDatasets = async (teamId: string) => {
teamId,
datasetIds: datasets.map((d) => d._id)
});
await MongoDataset.updateMany(
{
teamId
},
{
$set: {
deleteTime: new Date()
await mongoSessionRun(async (session) => {
await MongoDataset.updateMany(
{
teamId
},
{
$set: {
deleteTime: new Date()
}
},
{
session
}
}
);
await Promise.all(
datasets.map((dataset) => {
if (dataset.parentId) return;
return addDatasetDeleteJob({
teamId,
datasetId: dataset._id
});
})
);
);
await Promise.all(
datasets.map((dataset) => {
// 有 parentId 的忽略,只需要删 root 下的即可。
if (dataset.parentId) return;
return addDatasetDeleteJob({
teamId,
datasetId: dataset._id
});
})
);
});
};
// 批量删除函数
@@ -25,7 +25,10 @@ import {
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils';
import { getHTTPToolRuntimeNode } from '@fastgpt/global/core/app/tool/httpTool/utils';
import {
getHTTPToolRuntimeNode,
parseHttpToolConfig
} from '@fastgpt/global/core/app/tool/httpTool/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { MongoApp } from '../../../core/app/schema';
import { getMCPChildren } from '../../../core/app/mcp';
@@ -40,6 +43,7 @@ import { presignVariablesFileUrls } from '../../chat/utils';
import { getSystemTime } from '@fastgpt/global/common/time/timezone';
import { parsetMcpToolConfig } from '@fastgpt/global/core/app/tool/mcpTool/utils';
import { getMcpToolsets } from '../../app/tool/mcpTool/entity';
import { getHttpToolsets } from '../../app/tool/httpTool/entity';
import { getHTTPToolList } from '../../app/http';
/* get system variable */
@@ -396,7 +400,7 @@ export const rewriteRuntimeWorkFlow = async ({
}) => {
/* Toolset 展开 */
// TODO: 待性能优化
{
const parseToolset = async () => {
const toolSetNodes = nodes.filter((node) => node.flowNodeType === FlowNodeTypeEnum.toolSet);
if (toolSetNodes.length > 0) {
const nodeIdsToRemove = new Set<string>();
@@ -481,10 +485,10 @@ export const rewriteRuntimeWorkFlow = async ({
}
}
}
}
};
/* MCP tool 获取原始 schema 加入到 jsonschema 字段里 */
{
const parseMcpTool = async () => {
const mcpToolNodes = nodes.filter(
(node) => node.flowNodeType === FlowNodeTypeEnum.tool && node.toolConfig?.mcpTool
);
@@ -517,7 +521,45 @@ export const rewriteRuntimeWorkFlow = async ({
node.jsonSchema = toolRaw.inputSchema;
node.intro = toolRaw.description;
});
}
};
/* Http tool 获取原始 schema 加入到 jsonschema 字段里 */
const parseHttpTool = async () => {
const httpToolNodes = nodes.filter(
(node) => node.flowNodeType === FlowNodeTypeEnum.tool && node.toolConfig?.httpTool
);
const parseHttpToolConfigs = httpToolNodes
.map((node) => parseHttpToolConfig(node.toolConfig?.httpTool!))
.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<string, (typeof toolsets)[number]>();
toolsets.forEach((toolset) => {
toolsetMap.set(String(toolset._id), 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;
if (!toolList) return;
const toolRaw = toolList.find((tool) => tool.name === parseResult.toolName);
if (!toolRaw) return;
node.jsonSchema = toolRaw.requestSchema;
node.intro = toolRaw.description;
});
};
await Promise.all([parseToolset(), parseMcpTool(), parseHttpTool()]);
};
export const getNodeErrResponse = ({
@@ -30,6 +30,10 @@ async function handler(req: ApiRequestProps) {
fields: '_id'
});
const datasetIds = deleteDatasets.map((d) => d._id);
await deleteDatasetsImmediate({
teamId,
datasetIds
});
await mongoSessionRun(async (session) => {
// 1. Mark as deleted
@@ -46,11 +50,6 @@ async function handler(req: ApiRequestProps) {
}
);
await deleteDatasetsImmediate({
teamId,
datasetIds
});
// 2. Add to delete queue
await addDatasetDeleteJob({
teamId,
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
getHTTPToolSetRuntimeNode,
getHTTPToolRuntimeNode,
parseHttpToolConfig,
pathData2ToolList
} from '@fastgpt/global/core/app/tool/httpTool/utils';
import {
@@ -159,6 +160,72 @@ describe('httpTool utils', () => {
});
});
describe('parseHttpToolConfig', () => {
it('should parse toolsetId and toolName from a valid toolId', () => {
const result = parseHttpToolConfig({
toolId: 'http-toolset-456/someTool'
});
expect(result).toEqual({ toolsetId: 'toolset-456', toolName: 'someTool' });
});
it('should return undefined when toolId does not match http- prefix pattern', () => {
const result = parseHttpToolConfig({
toolId: 'mcp-foo/bar'
});
expect(result).toBeUndefined();
});
it('should return undefined when toolId has no slash separator', () => {
const result = parseHttpToolConfig({
toolId: 'http-toolset-no-tool'
});
expect(result).toBeUndefined();
});
it('should return undefined when toolsetId segment is empty in toolId', () => {
const result = parseHttpToolConfig({
toolId: 'http-/toolName'
});
expect(result).toBeUndefined();
});
it('should return undefined when toolName segment is empty in toolId', () => {
const result = parseHttpToolConfig({
toolId: 'http-toolset-abc/'
});
expect(result).toBeUndefined();
});
it('should return undefined when toolId is empty string', () => {
const result = parseHttpToolConfig({
toolId: ''
});
expect(result).toBeUndefined();
});
it('should preserve slashes inside tool name', () => {
const result = parseHttpToolConfig({
toolId: 'http-toolset-abc/namespace/nestedTool'
});
expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace/nestedTool' });
});
it('should preserve multiple slashes inside tool name', () => {
const result = parseHttpToolConfig({
toolId: 'http-toolset-xyz/a/b/c/d'
});
expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' });
});
});
describe('pathData2ToolList', () => {
it('should convert simple path data to tool list', async () => {
const pathData: PathDataType[] = [
@@ -191,12 +191,28 @@ describe('mcpTool utils', () => {
expect(result).toBeUndefined();
});
it('should parse toolsetId correctly when tool name contains additional slashes', () => {
it('should preserve slashes inside tool name', () => {
const result = parsetMcpToolConfig({
toolId: 'mcp-toolset-abc/namespace/nestedTool'
});
expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace' });
expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace/nestedTool' });
});
it('should preserve multiple slashes inside tool name', () => {
const result = parsetMcpToolConfig({
toolId: 'mcp-toolset-xyz/a/b/c/d'
});
expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' });
});
it('should return undefined when toolName segment is empty in toolId', () => {
const result = parsetMcpToolConfig({
toolId: 'mcp-toolset-abc/'
});
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockMongoAppFind } = vi.hoisted(() => ({
mockMongoAppFind: vi.fn()
}));
vi.mock('@fastgpt/service/core/app/schema', () => ({
MongoApp: {
find: mockMongoAppFind
}
}));
import { getHttpToolsets } from '@fastgpt/service/core/app/tool/httpTool/entity';
const setupFindReturn = (result: any) => {
const leanFn = vi.fn().mockResolvedValue(result);
mockMongoAppFind.mockReturnValue({ lean: leanFn });
return { leanFn };
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getHttpToolsets', () => {
it('should query MongoApp with teamId, ids, and field, and return lean result', async () => {
const docs = [
{ _id: 'id1', modules: [{ toolConfig: { httpToolSet: { toolList: [] } } }] },
{ _id: 'id2', modules: [] }
];
const { leanFn } = setupFindReturn(docs);
const field = { _id: true, modules: true };
const res = await getHttpToolsets({
teamId: 'team1',
ids: ['id1', 'id2'],
field
});
expect(mockMongoAppFind).toHaveBeenCalledTimes(1);
expect(mockMongoAppFind).toHaveBeenCalledWith(
{ teamId: 'team1', _id: { $in: ['id1', 'id2'] } },
field
);
expect(leanFn).toHaveBeenCalledTimes(1);
expect(res).toBe(docs);
});
it('should pass undefined field when not provided', async () => {
setupFindReturn([]);
await getHttpToolsets({ teamId: 'team1', ids: ['id1'] });
expect(mockMongoAppFind).toHaveBeenCalledWith(
{ teamId: 'team1', _id: { $in: ['id1'] } },
undefined
);
});
it('should handle empty ids array', async () => {
const { leanFn } = setupFindReturn([]);
const res = await getHttpToolsets({ teamId: 'team1', ids: [] });
expect(mockMongoAppFind).toHaveBeenCalledWith({ teamId: 'team1', _id: { $in: [] } }, undefined);
expect(leanFn).toHaveBeenCalledTimes(1);
expect(res).toEqual([]);
});
it('should propagate rejection from lean()', async () => {
const leanFn = vi.fn().mockRejectedValue(new Error('db error'));
mockMongoAppFind.mockReturnValue({ lean: leanFn });
await expect(
getHttpToolsets({ teamId: 'team1', ids: ['id1'], field: { _id: true } })
).rejects.toThrow('db error');
});
});
@@ -12,8 +12,11 @@ import {
formatHttpError,
rewriteRuntimeWorkFlow,
getNodeErrResponse,
safePoints
safePoints,
getSystemVariables
} from '@fastgpt/service/core/workflow/dispatch/utils';
import { encryptSecret } from '@fastgpt/service/common/secret/aes256gcm';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { responseWrite } from '@fastgpt/service/common/response';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
@@ -51,6 +54,11 @@ vi.mock('@fastgpt/service/core/app/http', () => ({
getHTTPToolList: (...args: any[]) => mockGetHTTPToolList(...args)
}));
const mockPresignVariablesFileUrls = vi.fn();
vi.mock('@fastgpt/service/core/chat/utils', () => ({
presignVariablesFileUrls: (...args: any[]) => mockPresignVariablesFileUrls(...args)
}));
describe('getWorkflowResponseWrite', () => {
const mockRes = () => {
const res: any = { closed: false };
@@ -804,6 +812,170 @@ describe('rewriteRuntimeWorkFlow', () => {
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.
const setupFindByIdMap = (idToDoc: Record<string, any>) => {
mockMongoAppFind.mockImplementation((query: any) => {
const ids: string[] = query?._id?.$in ?? [];
const docs = ids.map((id) => idToDoc[id]).filter(Boolean);
return { lean: vi.fn().mockResolvedValue(docs) };
});
};
it('should inject jsonSchema and intro for standalone MCP tool nodes', async () => {
const mcpToolNode = makeNode('mcp1', FlowNodeTypeEnum.tool, {
toolConfig: {
mcpTool: { toolId: 'mcp-toolset-1/toolA' }
}
} as any);
const nodes = [mcpToolNode];
const edges: RuntimeEdgeItemType[] = [];
const toolAInputSchema = {
type: 'object',
properties: { x: { type: 'string' } }
};
setupFindByIdMap({
'toolset-1': {
_id: 'toolset-1',
modules: [
{
toolConfig: {
mcpToolSet: {
toolList: [
{
name: 'toolA',
description: 'tool A description',
inputSchema: toolAInputSchema
}
]
}
}
}
]
}
});
await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges });
expect(mcpToolNode.jsonSchema).toEqual(toolAInputSchema);
expect(mcpToolNode.intro).toBe('tool A description');
});
it('should inject jsonSchema and intro for standalone HTTP tool nodes', async () => {
const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, {
toolConfig: {
httpTool: { toolId: 'http-toolset-1/toolB' }
}
} as any);
const nodes = [httpToolNode];
const edges: RuntimeEdgeItemType[] = [];
const toolBRequestSchema = {
type: 'object',
properties: { y: { type: 'number' } }
};
setupFindByIdMap({
'toolset-1': {
_id: 'toolset-1',
modules: [
{
toolConfig: {
httpToolSet: {
toolList: [
{
name: 'toolB',
description: 'tool B description',
requestSchema: toolBRequestSchema
}
]
}
}
}
]
}
});
await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges });
expect(httpToolNode.jsonSchema).toEqual(toolBRequestSchema);
expect(httpToolNode.intro).toBe('tool B description');
});
it('should preserve tool names containing slashes when injecting schema', async () => {
const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, {
toolConfig: {
httpTool: { toolId: 'http-toolset-1/namespace/toolC' }
}
} as any);
const nodes = [httpToolNode];
const edges: RuntimeEdgeItemType[] = [];
setupFindByIdMap({
'toolset-1': {
_id: 'toolset-1',
modules: [
{
toolConfig: {
httpToolSet: {
toolList: [
{
name: 'namespace/toolC',
description: 'nested tool',
requestSchema: { type: 'object' }
}
]
}
}
}
]
}
});
await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges });
expect(httpToolNode.jsonSchema).toEqual({ type: 'object' });
expect(httpToolNode.intro).toBe('nested tool');
});
it('should skip schema injection when toolset is not found', async () => {
const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, {
toolConfig: {
httpTool: { toolId: 'http-missing/toolX' }
},
intro: 'original',
jsonSchema: { type: 'original' }
} as any);
const nodes = [httpToolNode];
const edges: RuntimeEdgeItemType[] = [];
setupFindByIdMap({});
await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges });
expect(httpToolNode.jsonSchema).toEqual({ type: 'original' });
expect(httpToolNode.intro).toBe('original');
});
it('should skip schema injection when toolId prefix does not match', async () => {
const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, {
toolConfig: {
httpTool: { toolId: 'mcp-toolset-1/toolA' }
},
intro: 'original',
jsonSchema: { type: 'original' }
} as any);
const nodes = [httpToolNode];
const edges: RuntimeEdgeItemType[] = [];
setupFindByIdMap({});
await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges });
expect(httpToolNode.jsonSchema).toEqual({ type: 'original' });
expect(httpToolNode.intro).toBe('original');
});
});
describe('getNodeErrResponse', () => {
@@ -893,3 +1065,306 @@ describe('safePoints', () => {
expect(safePoints(undefined)).toBe(0);
});
});
describe('getSystemVariables', () => {
const baseArgs = {
timezone: 'Asia/Shanghai',
runningAppInfo: { id: 'app-123' } as any,
chatId: 'chat-1',
responseChatItemId: 'rci-1',
histories: [],
uid: 'user-1',
chatConfig: {} as any,
variables: {}
};
it('should return only system variables when chatConfig has no variables', async () => {
const result = await getSystemVariables(baseArgs);
expect(result.userId).toBe('user-1');
expect(result.appId).toBe('app-123');
expect(result.chatId).toBe('chat-1');
expect(result.responseChatItemId).toBe('rci-1');
expect(result.histories).toEqual([]);
expect(typeof result.cTime).toBe('string');
});
it('should return only system variables when chatConfig is undefined', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: undefined as any
});
expect(result.userId).toBe('user-1');
expect(result.appId).toBe('app-123');
expect((result as any).custom).toBeUndefined();
});
it('should coerce runningAppInfo.id to string', async () => {
const result = await getSystemVariables({
...baseArgs,
runningAppInfo: { id: 42 } as any
});
expect(result.appId).toBe('42');
});
it('should default histories to [] when not provided', async () => {
const { histories, ...rest } = baseArgs;
const result = await getSystemVariables(rest as any);
expect(result.histories).toEqual([]);
});
it('should preserve provided histories', async () => {
const histories = [{ obj: 'Human', value: [] }] as any;
const result = await getSystemVariables({
...baseArgs,
histories
});
expect(result.histories).toBe(histories);
});
it('should decrypt password variable from item.label', async () => {
const encrypted = encryptSecret('plain-value');
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'pwdKey',
label: 'pwdLabel',
type: VariableInputEnum.password,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
pwdLabel: JSON.stringify({ value: '', secret: encrypted })
}
});
expect((result as any).pwdKey).toBe('plain-value');
});
it('should decrypt password variable from item.key when label missing', async () => {
const encrypted = encryptSecret('key-value');
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'pwdKey',
label: 'pwdLabel',
type: VariableInputEnum.password,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
pwdKey: { value: '', secret: encrypted }
}
});
expect((result as any).pwdKey).toBe('key-value');
});
it('should fall back to defaultValue for password variable', async () => {
const encrypted = encryptSecret('default-value');
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'pwdKey',
label: 'pwdLabel',
type: VariableInputEnum.password,
valueType: WorkflowIOValueTypeEnum.string,
defaultValue: { value: '', secret: encrypted }
}
]
} as any,
variables: {}
});
expect((result as any).pwdKey).toBe('default-value');
});
it('should map file variables to their url list', async () => {
mockPresignVariablesFileUrls.mockResolvedValueOnce({
fileKey: [
{ key: 'a', url: 'http://example.com/a' },
{ key: 'b', url: 'http://example.com/b' }
]
});
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'fileKey',
label: 'fileLabel',
type: VariableInputEnum.file,
valueType: WorkflowIOValueTypeEnum.arrayString
}
]
} as any,
variables: {
fileKey: [{ key: 'a' }, { key: 'b' }]
}
});
expect((result as any).fileKey).toEqual(['http://example.com/a', 'http://example.com/b']);
});
it('should return undefined for file variable when presign returns no entry', async () => {
mockPresignVariablesFileUrls.mockResolvedValueOnce({});
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'fileKey',
label: 'fileLabel',
type: VariableInputEnum.file,
valueType: WorkflowIOValueTypeEnum.arrayString
}
]
} as any,
variables: {}
});
expect((result as any).fileKey).toBeUndefined();
});
it('should use variables[label] when provided (API input)', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'myKey',
label: 'myLabel',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
myLabel: 'from-label'
}
});
expect((result as any).myKey).toBe('from-label');
});
it('should use variables[key] when label missing (Web input)', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'myKey',
label: 'myLabel',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
myKey: 'from-key'
}
});
expect((result as any).myKey).toBe('from-key');
});
it('should fall back to defaultValue when neither label nor key provided', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'myKey',
label: 'myLabel',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string,
defaultValue: 'fallback'
}
]
} as any,
variables: {}
});
expect((result as any).myKey).toBe('fallback');
});
it('should format value by valueType (number)', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'n',
label: 'nLabel',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number
}
]
} as any,
variables: {
nLabel: '42'
}
});
expect((result as any).n).toBe(42);
});
it('should prefer label over key when both present', async () => {
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'dup',
label: 'dupLabel',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
dupLabel: 'label-wins',
dup: 'key-loses'
}
});
expect((result as any).dup).toBe('label-wins');
});
it('should process multiple variables in the same call', async () => {
mockPresignVariablesFileUrls.mockResolvedValueOnce({
f: [{ key: 'k', url: 'http://example.com/k' }]
});
const encrypted = encryptSecret('pwd-plain');
const result = await getSystemVariables({
...baseArgs,
chatConfig: {
variables: [
{
key: 'a',
label: 'aLabel',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string
},
{
key: 'f',
label: 'fLabel',
type: VariableInputEnum.file,
valueType: WorkflowIOValueTypeEnum.arrayString
},
{
key: 'p',
label: 'pLabel',
type: VariableInputEnum.password,
valueType: WorkflowIOValueTypeEnum.string
}
]
} as any,
variables: {
aLabel: 'alpha',
f: [{ key: 'k' }],
pLabel: JSON.stringify({ value: '', secret: encrypted })
}
});
expect((result as any).a).toBe('alpha');
expect((result as any).f).toEqual(['http://example.com/k']);
expect((result as any).p).toBe('pwd-plain');
});
});