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

@@ -3,7 +3,7 @@
"version": "1.0.0",
"dependencies": {
"@fastgpt/global": "workspace:*",
"@modelcontextprotocol/sdk": "^1.10.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@node-rs/jieba": "2.0.1",
"@xmldom/xmldom": "^0.8.10",
"@zilliz/milvus2-sdk-node": "2.4.2",

View File

@@ -12,9 +12,11 @@
"mcp_apps": "Number of associated applications",
"mcp_endpoints": "Access address",
"mcp_json_config": "Access script",
"mcp_link_way": "Access method",
"mcp_name": "MCP service name",
"mcp_server": "MCP Services",
"mcp_server_description": "Allows you to select some applications to provide external use with the MCP protocol. \nDue to the immaturity of the MCP protocol, this feature is still in the beta stage.",
"not_sse_server": "The system does not configure SSE access service",
"search_app": "Search for apps",
"select_app": "Application selection",
"start_use": "Get started",

View File

@@ -12,9 +12,11 @@
"mcp_apps": "关联应用数量",
"mcp_endpoints": "接入地址",
"mcp_json_config": "接入脚本",
"mcp_link_way": "接入方式",
"mcp_name": "MCP 服务名",
"mcp_server": "MCP 服务",
"mcp_server_description": "允许你选择部分应用,以 MCP 的协议对外提供使用。由于 MCP 协议的不成熟,该功能仍处于测试阶段。",
"not_sse_server": "系统未配置 SSE 接入服务",
"search_app": "搜索应用",
"select_app": "应用选择",
"start_use": "开始使用",

View File

@@ -12,9 +12,11 @@
"mcp_apps": "關聯應用數量",
"mcp_endpoints": "接入地址",
"mcp_json_config": "接入腳本",
"mcp_link_way": "接入方式",
"mcp_name": "MCP 服務名",
"mcp_server": "MCP 服務",
"mcp_server_description": "允許你選擇部分應用,以 MCP 的協議對外提供使用。\n由於 MCP 協議的不成熟,該功能仍處於測試階段。",
"not_sse_server": "系統未配置 SSE 接入服務",
"search_app": "搜索應用",
"select_app": "應用選擇",
"start_use": "開始使用",

96
pnpm-lock.yaml generated
View File

@@ -82,7 +82,7 @@ importers:
version: 5.1.3
next:
specifier: 14.2.26
version: 14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
version: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
openai:
specifier: 4.61.0
version: 4.61.0(encoding@0.1.13)(zod@3.24.2)
@@ -164,7 +164,7 @@ importers:
specifier: workspace:*
version: link:../global
'@modelcontextprotocol/sdk':
specifier: ^1.10.0
specifier: ^1.10.2
version: 1.10.2
'@node-rs/jieba':
specifier: 2.0.1
@@ -246,7 +246,7 @@ importers:
version: 3.13.0
next:
specifier: 14.2.26
version: 14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
version: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
nextjs-cors:
specifier: ^2.2.0
version: 2.2.0(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))
@@ -455,7 +455,7 @@ importers:
version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@chakra-ui/next-js':
specifier: 2.4.2
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
'@chakra-ui/react':
specifier: 2.10.7
version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -493,7 +493,7 @@ importers:
specifier: ^3.0.6
version: 3.0.6
'@modelcontextprotocol/sdk':
specifier: ^1.10.0
specifier: ^1.10.2
version: 1.10.2
'@node-rs/jieba':
specifier: 2.0.1
@@ -560,7 +560,7 @@ importers:
version: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
next-i18next:
specifier: 15.4.2
version: 15.4.2(i18next@23.16.8)(next@14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.2(i18next@23.16.8)(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
nprogress:
specifier: ^0.2.0
version: 0.2.0
@@ -677,8 +677,8 @@ importers:
projects/mcp_server:
dependencies:
'@modelcontextprotocol/sdk':
specifier: 1.9.0
version: 1.9.0
specifier: ^1.10.2
version: 1.10.2
axios:
specifier: ^1.8.2
version: 1.8.4
@@ -2369,10 +2369,6 @@ packages:
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'}
'@modelcontextprotocol/sdk@1.9.0':
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
engines: {node: '>=18'}
'@monaco-editor/loader@1.5.0':
resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==}
@@ -11012,20 +11008,12 @@ snapshots:
'@chakra-ui/system': 2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)':
dependencies:
'@chakra-ui/react': 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emotion/cache': 11.14.0
'@emotion/react': 11.11.1(@types/react@18.3.1)(react@18.3.1)
next: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
react: 18.3.1
'@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)':
dependencies:
'@chakra-ui/react': 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emotion/cache': 11.14.0
'@emotion/react': 11.11.1(@types/react@18.3.1)(react@18.3.1)
next: 14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
next: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
react: 18.3.1
'@chakra-ui/object-utils@2.1.0': {}
@@ -11909,21 +11897,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/sdk@1.9.0':
dependencies:
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.6
express: 5.1.0
express-rate-limit: 7.5.0(express@5.1.0)
pkce-challenge: 5.0.0
raw-body: 3.0.0
zod: 3.24.2
zod-to-json-schema: 3.24.5(zod@3.24.2)
transitivePeerDependencies:
- supports-color
'@monaco-editor/loader@1.5.0':
dependencies:
state-local: 1.0.7
@@ -13705,7 +13678,7 @@ snapshots:
axios@1.8.3:
dependencies:
follow-redirects: 1.15.9
follow-redirects: 1.15.9(debug@4.4.0)
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
@@ -15667,8 +15640,6 @@ snapshots:
dependencies:
tslib: 2.8.1
follow-redirects@1.15.9: {}
follow-redirects@1.15.9(debug@4.4.0):
optionalDependencies:
debug: 4.4.0(supports-color@5.5.0)
@@ -18121,18 +18092,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
next-i18next@15.4.2(i18next@23.16.8)(next@14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
'@types/hoist-non-react-statics': 3.3.6
core-js: 3.41.0
hoist-non-react-statics: 3.3.2
i18next: 23.16.8
i18next-fs-backend: 2.6.0
next: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
react: 18.3.1
react-i18next: 14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-i18next@15.4.2(i18next@23.16.8)(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
@@ -18141,7 +18100,7 @@ snapshots:
hoist-non-react-statics: 3.3.2
i18next: 23.16.8
i18next-fs-backend: 2.6.0
next: 14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
next: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
react: 18.3.1
react-i18next: 14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -18171,36 +18130,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1):
dependencies:
'@next/env': 14.2.26
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001704
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.1(react@18.3.1)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.26
'@next/swc-darwin-x64': 14.2.26
'@next/swc-linux-arm64-gnu': 14.2.26
'@next/swc-linux-arm64-musl': 14.2.26
'@next/swc-linux-x64-gnu': 14.2.26
'@next/swc-linux-x64-musl': 14.2.26
'@next/swc-win32-arm64-msvc': 14.2.26
'@next/swc-win32-ia32-msvc': 14.2.26
'@next/swc-win32-x64-msvc': 14.2.26
sass: 1.85.1
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
nextjs-cors@2.2.0(next@14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)):
dependencies:
cors: 2.8.5
next: 14.2.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
next: 14.2.26(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
node-abi@3.74.0:
dependencies:
@@ -19880,11 +19813,6 @@ snapshots:
optionalDependencies:
'@babel/core': 7.26.10
styled-jsx@5.1.1(react@18.3.1):
dependencies:
client-only: 0.0.1
react: 18.3.1
stylis@4.2.0: {}
stylis@4.3.6: {}

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);
};

View File

@@ -14,7 +14,7 @@
"mcp_test": "npx @modelcontextprotocol/inspector"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.9.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"axios": "^1.8.2",
"chalk": "^5.3.0",
"dayjs": "^1.11.7",

View File

@@ -1,8 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import {
pluginNodes2InputSchema,
workflow2InputSchema
} from '@/pages/api/support/mcp/server/toolList';
import { pluginNodes2InputSchema, workflow2InputSchema } from '@/service/support/mcp/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
VariableInputEnum,
@@ -22,7 +19,8 @@ vi.mock('@fastgpt/service/core/app/schema', () => ({
find: vi.fn().mockReturnValue({
lean: vi.fn()
})
}
},
AppCollectionName: 'apps'
}));
vi.mock('@fastgpt/service/core/app/version/controller', () => ({