feat: streamable http mcp (#4695)

* feat: streamable http mcp

* mcp api path

* fix: ts
This commit is contained in:
Archer
2025-04-28 12:45:51 +08:00
committed by GitHub
parent d91b2ae303
commit ca8adbbf95
15 changed files with 562 additions and 457 deletions

View File

@@ -24,7 +24,7 @@
"@fastgpt/templates": "workspace:*",
"@fastgpt/web": "workspace:*",
"@fortaine/fetch-event-source": "^3.0.6",
"@modelcontextprotocol/sdk": "^1.10.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@node-rs/jieba": "2.0.1",
"@tanstack/react-query": "^4.24.10",
"ahooks": "^3.7.11",

View File

@@ -184,16 +184,12 @@ const DashboardContainer = ({
: [])
]
},
...(feConfigs?.mcpServerProxyEndpoint
? [
{
groupId: TabEnum.mcp_server,
groupAvatar: 'key',
groupName: t('common:mcp_server'),
children: []
}
]
: [])
{
groupId: TabEnum.mcp_server,
groupAvatar: 'key',
groupName: t('common:mcp_server'),
children: []
}
];
}, [currentType, feConfigs.appTemplateCourse, pluginGroups, t, templateList, templateTags]);

View File

@@ -1,59 +1,113 @@
import { McpKeyType } from '@fastgpt/global/support/mcp/type';
import MyModal from '@fastgpt/web/components/common/MyModal';
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Box, Flex, HStack, ModalBody } from '@chakra-ui/react';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import CopyBox from '@fastgpt/web/components/common/String/CopyBox';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
type LinkWay = 'sse' | 'http';
const UsageWay = ({ mcp, onClose }: { mcp: McpKeyType; onClose: () => void }) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const [linkWay, setLinkWay] = useState<LinkWay>('http');
const sseUrl = `${feConfigs?.mcpServerProxyEndpoint}/${mcp.key}/sse`;
const jsonConfig = `{
const { url, jsonConfig } = (() => {
if (linkWay === 'http') {
const baseUrl = feConfigs?.customApiDomain || `${location.origin}/api`;
const url = `${baseUrl}/mcp/app/${mcp.key}/mcp`;
const jsonConfig = `{
"mcpServers": {
"${feConfigs?.systemTitle}-mcp-${mcp._id}": {
"url": "${sseUrl}"
"url": "${url}"
}
}
}`;
return {
url,
jsonConfig
};
}
const url = feConfigs?.mcpServerProxyEndpoint
? `${feConfigs?.mcpServerProxyEndpoint}/${mcp.key}/sse`
: '';
const jsonConfig = `{
"mcpServers": {
"${feConfigs?.systemTitle}-mcp-${mcp._id}": {
"url": "${url}"
}
}
}`;
return {
url,
jsonConfig
};
})();
return (
<MyModal isOpen title={t('dashboard_mcp:usage_way')} onClose={onClose}>
<MyModal iconSrc="key" isOpen title={t('dashboard_mcp:usage_way')} onClose={onClose}>
<ModalBody>
<Box>
<FormLabel>{t('dashboard_mcp:mcp_endpoints')}</FormLabel>
<HStack mt={0.5} bg={'myGray.50'} px={2} py={1} borderRadius={'md'} fontSize={'sm'}>
<Box userSelect={'all'} flex={'1 0 0'} whiteSpace={'pre-wrap'} wordBreak={'break-all'}>
{sseUrl}
<Flex>
<LightRowTabs<LinkWay>
m={'auto'}
w={'100%'}
list={[
{ label: 'Streamable HTTP', value: 'http' },
{ label: 'SSE', value: 'sse' }
]}
value={linkWay}
onChange={setLinkWay}
/>
</Flex>
{url ? (
<>
<Box mt={4}>
<FormLabel>{t('dashboard_mcp:mcp_endpoints')}</FormLabel>
<HStack mt={0.5} bg={'myGray.50'} px={2} py={1} borderRadius={'md'} fontSize={'sm'}>
<Box
userSelect={'all'}
flex={'1 0 0'}
whiteSpace={'pre-wrap'}
wordBreak={'break-all'}
>
{url}
</Box>
<CopyBox value={url}>
<MyIconButton icon="copy" />
</CopyBox>
</HStack>
</Box>
<CopyBox value={sseUrl}>
<MyIconButton icon="copy" />
</CopyBox>
</HStack>
</Box>
<Box mt={4}>
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
<Flex
p={3}
bg={'myWhite.500'}
border={'base'}
borderTopLeftRadius={'md'}
borderTopRightRadius={'md'}
>
<Box flex={1}>{t('dashboard_mcp:mcp_json_config')}</Box>
<CopyBox value={jsonConfig}>
<MyIconButton icon="copy" />
</CopyBox>
</Flex>
<Box whiteSpace={'pre-wrap'} wordBreak={'break-all'} p={3} overflowX={'auto'}>
{jsonConfig}
<Box mt={4}>
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
<Flex
p={3}
bg={'myWhite.500'}
border={'base'}
borderTopLeftRadius={'md'}
borderTopRightRadius={'md'}
>
<Box flex={1}>{t('dashboard_mcp:mcp_json_config')}</Box>
<CopyBox value={jsonConfig}>
<MyIconButton icon="copy" />
</CopyBox>
</Flex>
<Box whiteSpace={'pre-wrap'} wordBreak={'break-all'} p={3} overflowX={'auto'}>
{jsonConfig}
</Box>
</Box>
</Box>
</Box>
</Box>
</>
) : (
<Flex h={'200px'} justifyContent={'center'} alignItems={'center'}>
{t('dashboard_mcp:not_sse_server')}
</Flex>
)}
</ModalBody>
</MyModal>
);

View File

@@ -0,0 +1,115 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { addLog } from '@fastgpt/service/common/system/log';
import {
CallToolRequestSchema,
CallToolResult,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types';
import { callMcpServerTool, getMcpServerTools } from '@/service/support/mcp/utils';
import { toolCallProps } from '@/service/support/mcp/type';
import { getErrText } from '@fastgpt/global/common/error/utils';
export type mcpQuery = { key: string };
export type mcpBody = toolCallProps;
const handlePost = async (req: ApiRequestProps<mcpBody, mcpQuery>, res: ApiResponseType<any>) => {
const key = req.query.key;
const server = new Server(
{
name: 'fastgpt-mcp-server-http-streamable',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
res.on('close', () => {
addLog.debug('[MCP server] Close connection');
transport.close();
server.close();
});
try {
const tools = await getMcpServerTools(key);
// Register list tools
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools
}));
// Register call tool
const handleToolCall = async (
name: string,
args: Record<string, any>
): Promise<CallToolResult> => {
try {
addLog.debug(`Call tool: ${name} with args: ${JSON.stringify(args)}`);
const result = await callMcpServerTool({ key, toolName: name, inputs: args });
return {
content: [
{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result)
}
],
isError: false
};
} catch (error) {
return {
message: getErrText(error),
content: [],
isError: true
};
}
};
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handleToolCall(request.params.name, request.params.arguments ?? {});
});
// Connect to transport
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
addLog.error('[MCP server] Error handling MCP request:', error);
if (!res.writableFinished) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
};
async function handler(req: ApiRequestProps<mcpBody, mcpQuery>, res: ApiResponseType<any>) {
const method = req.method;
if (method === 'POST') {
return handlePost(req, res);
}
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.'
},
id: null
})
);
return;
}
export default handler;

View File

@@ -1,199 +1,19 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppSchema } from '@fastgpt/global/core/app/type';
import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team';
import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import {
getPluginRunUserQuery,
updatePluginInputByVariables
} from '@fastgpt/global/core/workflow/utils';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import {
getWorkflowEntryNodeIds,
storeEdges2RuntimeEdges,
storeNodes2RuntimeNodes
} from '@fastgpt/global/core/workflow/runtime/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch';
import { getChatTitleFromChatMessage, removeEmptyUserInput } from '@fastgpt/global/core/chat/utils';
import { saveChat } from '@fastgpt/service/core/chat/saveChat';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { toolCallProps } from '@/service/support/mcp/type';
import { callMcpServerTool } from '@/service/support/mcp/utils';
export type toolCallQuery = {};
export type toolCallBody = {
key: string;
toolName: string;
inputs: Record<string, any>;
};
export type toolCallBody = toolCallProps;
export type toolCallResponse = {};
const dispatchApp = async (app: AppSchema, variables: Record<string, any>) => {
const isPlugin = app.type === AppTypeEnum.plugin;
const { timezone, externalProvider } = await getUserChatInfoAndAuthTeamPoints(app.tmbId);
// Get app latest version
const { nodes, edges, chatConfig } = await getAppLatestVersion(app._id, app);
const userQuestion: UserChatItemType = (() => {
if (isPlugin) {
return getPluginRunUserQuery({
pluginInputs: getPluginInputsFromStoreNodes(nodes || app.modules),
variables
});
}
return {
obj: ChatRoleEnum.Human,
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: variables.question
}
}
]
};
})();
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes));
if (isPlugin) {
// Assign values to runtimeNodes using variables
runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables);
// Plugin runtime does not need global variables(It has been injected into the pluginInputNode)
variables = {};
} else {
delete variables.question;
variables.system_fileUrlList = variables.fileUrlList;
delete variables.fileUrlList;
}
const chatId = getNanoid();
const { flowUsages, assistantResponses, newVariables, flowResponses, durationSeconds } =
await dispatchWorkFlow({
chatId,
timezone,
externalProvider,
mode: 'chat',
runningAppInfo: {
id: String(app._id),
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
runningUserInfo: {
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
uid: String(app.tmbId),
runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges),
variables,
query: removeEmptyUserInput(userQuestion.value),
chatConfig,
histories: [],
stream: false,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES
});
// Save chat
const aiResponse: AIChatItemType & { dataId?: string } = {
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
const newTitle = isPlugin ? 'Mcp call' : getChatTitleFromChatMessage(userQuestion);
await saveChat({
chatId,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: false, // owner update use time
newTitle,
source: ChatSourceEnum.mcp,
content: [userQuestion, aiResponse],
durationSeconds
});
// Push usage
createChatUsage({
appName: app.name,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
source: UsageSourceEnum.mcp,
flowUsages
});
// Get MCP response type
const responseContent = (() => {
if (isPlugin) {
const output = flowResponses.find(
(item) => item.moduleType === FlowNodeTypeEnum.pluginOutput
);
if (output) {
return JSON.stringify(output.pluginOutput);
} else {
return 'Can not get response from plugin';
}
}
return assistantResponses
.map((item) => item?.text?.content)
.filter(Boolean)
.join('\n');
})();
return responseContent;
};
async function handler(
req: ApiRequestProps<toolCallBody, toolCallQuery>,
res: ApiResponseType<any>
): Promise<toolCallResponse> {
const { key, toolName, inputs } = req.body;
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find({
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
}).lean();
const app = appList.find((app) => {
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
return toolName === mcpApp.toolName;
});
if (!app) {
return Promise.reject(CommonErrEnum.missingParams);
}
return await dispatchApp(app, inputs);
return callMcpServerTool(req.body);
}
export default NextAPI(handler);

View File

@@ -1,17 +1,7 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller';
import { Tool } from '@modelcontextprotocol/sdk/types';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { toolValueTypeList } from '@fastgpt/global/core/workflow/constants';
import { AppChatConfigType } from '@fastgpt/global/core/app/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { getMcpServerTools } from '@/service/support/mcp/utils';
export type listToolsQuery = { key: string };
@@ -19,139 +9,13 @@ export type listToolsBody = {};
export type listToolsResponse = {};
export const pluginNodes2InputSchema = (
nodes: { flowNodeType: FlowNodeTypeEnum; inputs: FlowNodeInputItemType[] }[]
) => {
const pluginInput = nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput);
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {},
required: []
};
pluginInput?.inputs.forEach((input) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === input.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![input.key] = {
...jsonSchema,
description: input.description,
enum: input.enum?.split('\n').filter(Boolean) || undefined
};
if (input.required) {
// @ts-ignore
schema.required.push(input.key);
}
});
return schema;
};
export const workflow2InputSchema = (chatConfig?: {
fileSelectConfig?: AppChatConfigType['fileSelectConfig'];
variables?: AppChatConfigType['variables'];
}) => {
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {
question: {
type: 'string',
description: 'Question from user'
},
...(chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg
? {
fileUrlList: {
type: 'array',
items: {
type: 'string'
},
description: 'File linkage'
}
}
: {})
},
required: ['question']
};
chatConfig?.variables?.forEach((item) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === item.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![item.key] = {
...jsonSchema,
description: item.description,
enum: item.enums?.map((enumItem) => enumItem.value) || undefined
};
if (item.required) {
// @ts-ignore
schema.required!.push(item.key);
}
});
return schema;
};
async function handler(
req: ApiRequestProps<listToolsBody, listToolsQuery>,
res: ApiResponseType<any>
): Promise<Tool[]> {
const { key } = req.query;
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find(
{
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
},
{ name: 1, intro: 1 }
).lean();
// Filter not permission app
const permissionAppList = await Promise.all(
appList.filter(async (app) => {
try {
await authAppByTmbId({ tmbId: mcp.tmbId, appId: app._id, per: ReadPermissionVal });
return true;
} catch (error) {
return false;
}
})
);
// Get latest version
const versionList = await Promise.all(
permissionAppList.map((app) => getAppLatestVersion(app._id, app))
);
// Compute mcp tools
const tools = versionList.map<Tool>((version, index) => {
const app = permissionAppList[index];
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
const isPlugin = !!version.nodes.find(
(node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput
);
return {
name: mcpApp.toolName,
description: mcpApp.description,
inputSchema: isPlugin
? pluginNodes2InputSchema(version.nodes)
: workflow2InputSchema(version.chatConfig)
};
});
return tools;
return getMcpServerTools(key);
}
export default NextAPI(handler);

View File

@@ -0,0 +1,5 @@
export type toolCallProps = {
key: string;
toolName: string;
inputs: Record<string, any>;
};

View File

@@ -0,0 +1,319 @@
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller';
import { Tool } from '@modelcontextprotocol/sdk/types';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { toolValueTypeList } from '@fastgpt/global/core/workflow/constants';
import { AppChatConfigType } from '@fastgpt/global/core/app/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { toolCallProps } from './type';
import { AppSchema } from '@fastgpt/global/core/app/type';
import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import {
getPluginRunUserQuery,
updatePluginInputByVariables
} from '@fastgpt/global/core/workflow/utils';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import {
getWorkflowEntryNodeIds,
storeEdges2RuntimeEdges,
storeNodes2RuntimeNodes
} from '@fastgpt/global/core/workflow/runtime/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch';
import { getChatTitleFromChatMessage, removeEmptyUserInput } from '@fastgpt/global/core/chat/utils';
import { saveChat } from '@fastgpt/service/core/chat/saveChat';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
export const pluginNodes2InputSchema = (
nodes: { flowNodeType: FlowNodeTypeEnum; inputs: FlowNodeInputItemType[] }[]
) => {
const pluginInput = nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput);
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {},
required: []
};
pluginInput?.inputs.forEach((input) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === input.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![input.key] = {
...jsonSchema,
description: input.description,
enum: input.enum?.split('\n').filter(Boolean) || undefined
};
if (input.required) {
// @ts-ignore
schema.required.push(input.key);
}
});
return schema;
};
export const workflow2InputSchema = (chatConfig?: {
fileSelectConfig?: AppChatConfigType['fileSelectConfig'];
variables?: AppChatConfigType['variables'];
}) => {
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {
question: {
type: 'string',
description: 'Question from user'
},
...(chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg
? {
fileUrlList: {
type: 'array',
items: {
type: 'string'
},
description: 'File linkage'
}
}
: {})
},
required: ['question']
};
chatConfig?.variables?.forEach((item) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === item.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![item.key] = {
...jsonSchema,
description: item.description,
enum: item.enums?.map((enumItem) => enumItem.value) || undefined
};
if (item.required) {
// @ts-ignore
schema.required!.push(item.key);
}
});
return schema;
};
export const getMcpServerTools = async (key: string): Promise<Tool[]> => {
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find(
{
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
},
{ name: 1, intro: 1 }
).lean();
// Filter not permission app
const permissionAppList = await Promise.all(
appList.filter(async (app) => {
try {
await authAppByTmbId({ tmbId: mcp.tmbId, appId: app._id, per: ReadPermissionVal });
return true;
} catch (error) {
return false;
}
})
);
// Get latest version
const versionList = await Promise.all(
permissionAppList.map((app) => getAppLatestVersion(app._id, app))
);
// Compute mcp tools
const tools = versionList.map<Tool>((version, index) => {
const app = permissionAppList[index];
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
const isPlugin = !!version.nodes.find(
(node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput
);
return {
name: mcpApp.toolName,
description: mcpApp.description,
inputSchema: isPlugin
? pluginNodes2InputSchema(version.nodes)
: workflow2InputSchema(version.chatConfig)
};
});
return tools;
};
// Call tool
export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps) => {
const dispatchApp = async (app: AppSchema, variables: Record<string, any>) => {
const isPlugin = app.type === AppTypeEnum.plugin;
const { timezone, externalProvider } = await getUserChatInfoAndAuthTeamPoints(app.tmbId);
// Get app latest version
const { nodes, edges, chatConfig } = await getAppLatestVersion(app._id, app);
const userQuestion: UserChatItemType = (() => {
if (isPlugin) {
return getPluginRunUserQuery({
pluginInputs: getPluginInputsFromStoreNodes(nodes || app.modules),
variables
});
}
return {
obj: ChatRoleEnum.Human,
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: variables.question
}
}
]
};
})();
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes));
if (isPlugin) {
// Assign values to runtimeNodes using variables
runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables);
// Plugin runtime does not need global variables(It has been injected into the pluginInputNode)
variables = {};
} else {
delete variables.question;
variables.system_fileUrlList = variables.fileUrlList;
delete variables.fileUrlList;
}
const chatId = getNanoid();
const { flowUsages, assistantResponses, newVariables, flowResponses, durationSeconds } =
await dispatchWorkFlow({
chatId,
timezone,
externalProvider,
mode: 'chat',
runningAppInfo: {
id: String(app._id),
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
runningUserInfo: {
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
uid: String(app.tmbId),
runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges),
variables,
query: removeEmptyUserInput(userQuestion.value),
chatConfig,
histories: [],
stream: false,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES
});
// Save chat
const aiResponse: AIChatItemType & { dataId?: string } = {
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
const newTitle = isPlugin ? 'Mcp call' : getChatTitleFromChatMessage(userQuestion);
await saveChat({
chatId,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: false, // owner update use time
newTitle,
source: ChatSourceEnum.mcp,
content: [userQuestion, aiResponse],
durationSeconds
});
// Push usage
createChatUsage({
appName: app.name,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
source: UsageSourceEnum.mcp,
flowUsages
});
// Get MCP response type
let responseContent = (() => {
if (isPlugin) {
const output = flowResponses.find(
(item) => item.moduleType === FlowNodeTypeEnum.pluginOutput
);
if (output) {
return JSON.stringify(output.pluginOutput);
} else {
return 'Can not get response from plugin';
}
}
return assistantResponses
.map((item) => item?.text?.content)
.filter(Boolean)
.join('\n');
})();
// Format response content
responseContent = responseContent.trim().replace(/\[\w+\]\(QUOTE\)/g, '');
return responseContent;
};
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find({
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
}).lean();
const app = appList.find((app) => {
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
return toolName === mcpApp.toolName;
});
if (!app) {
return Promise.reject(CommonErrEnum.missingParams);
}
return await dispatchApp(app, inputs);
};