From ca18a58e32b91ec36bb6eccbb8ecbf9b6ab3bc40 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 23 Jul 2026 17:51:55 +0800 Subject: [PATCH 1/3] feat(mcp): support auth proxy for server tool calls --- .agents/design/support/mcp-publish-auth.md | 51 ++++++++ .../global/openapi/support/mcpServer/api.ts | 43 +++++++ packages/global/support/mcp/type.ts | 6 + packages/service/support/mcp/schema.ts | 4 + .../service/support/permission/mcp/auth.ts | 3 +- packages/web/i18n/en/dashboard_mcp.json | 2 + packages/web/i18n/zh-CN/dashboard_mcp.json | 2 + packages/web/i18n/zh-Hant/dashboard_mcp.json | 2 + .../dashboard/mcp/EditModal.tsx | 26 +++- .../app/src/pages/api/mcp/app/[key]/mcp.ts | 4 +- .../app/src/pages/api/support/mcp/create.ts | 9 +- .../app/src/pages/api/support/mcp/list.ts | 9 +- .../pages/api/support/mcp/server/toolCall.ts | 9 +- .../pages/api/support/mcp/server/toolList.ts | 4 +- .../app/src/pages/api/support/mcp/update.ts | 18 ++- .../src/pages/dashboard/mcpServer/index.tsx | 1 + projects/app/src/service/support/mcp/auth.ts | 95 +++++++++++++++ projects/app/src/service/support/mcp/type.ts | 3 + projects/app/src/service/support/mcp/utils.ts | 37 ++++-- .../app/test/api/support/mcp/publish.test.ts | 111 +++++++++++++++++ .../api/support/mcp/server/toolList.test.ts | 15 ++- .../app/test/service/support/mcp/auth.test.ts | 115 ++++++++++++++++++ projects/mcp_server/src/api/fastgpt.ts | 13 +- projects/mcp_server/src/index.ts | 7 +- 24 files changed, 559 insertions(+), 30 deletions(-) create mode 100644 .agents/design/support/mcp-publish-auth.md create mode 100644 projects/app/src/service/support/mcp/auth.ts create mode 100644 projects/app/test/api/support/mcp/publish.test.ts create mode 100644 projects/app/test/service/support/mcp/auth.test.ts diff --git a/.agents/design/support/mcp-publish-auth.md b/.agents/design/support/mcp-publish-auth.md new file mode 100644 index 000000000000..bd01cb77f4cf --- /dev/null +++ b/.agents/design/support/mcp-publish-auth.md @@ -0,0 +1,51 @@ +# MCP 发布鉴权与身份代理 + +## 任务概述 + +MCP 发布能力与个人 APIKey 的管理和执行语义保持一致: + +- 管理列表仅返回当前团队成员创建的 MCP 发布项。 +- `tools/list` 是公开元数据接口,持有发布 key 即可读取工具描述,不校验发布者的实时应用权限。 +- `tools/call` 是执行接口,每次调用都使用有效团队成员身份校验目标应用读权限。 +- 团队 owner 可以为发布项开启 `authProxy`。调用方通过 + `x-fastgpt-auth-proxy-username` 或 `x-fastgpt-auth-proxy-tmb-id` 指定代理成员;两个请求头同时存在时必须指向同一成员。 + +## 设计 + +### 管理边界 + +`mcp_keys` 继续记录创建成员 `tmbId`。列表固定按 `{ teamId, tmbId }` 查询,更新和删除也只允许创建成员操作,团队管理权限不扩大到其他成员的发布项。 + +### 公开协议 + +`tools/list` 只根据发布 key 读取绑定应用及最新版本并生成 tool schema。该路径不解析身份代理,也不调用应用权限鉴权。 + +`tools/call` 根据发布 key 读取 `teamId`、`tmbId`、`authProxy` 和绑定应用: + +1. 未传身份代理时,以发布者 `tmbId` 作为有效成员。 +2. 传入身份代理时,要求发布项已开启 `authProxy`。 +3. username 和 tmbId 都只能解析到当前团队内未离开的成员;同时传入时必须匹配同一成员。 +4. 使用有效成员调用 `authAppByTmbId(..., ReadPermissionVal)`,通过后再运行工作流。 +5. 对话记录、运行用户信息和工作流 `uid` 都归属有效成员。 + +Streamable HTTP 直接读取请求头。独立 SSE 服务在建立连接时保存代理请求头,并在每次 `tools/call` 转发到主应用;`tools/list` 不转发身份信息。 + +### 兼容性 + +`authProxy` 缺省为 `false`,旧记录无需迁移。未使用代理头的现有 MCP 客户端仍以发布者身份执行;发布者失去目标应用读权限后,后续执行会立即失败。 + +## 风险与注意事项 + +- 发布 key 仍是执行凭证,需要按密钥管理;公开仅指工具描述无需额外用户登录态。 +- 身份代理不跨团队,已离开成员不能继续被代理。 +- SSE 连接只缓存调用方提供的代理标识,发布项开关与成员权限在每次执行时重新读取。 + +## TODO + +- [x] MCP schema、OpenAPI schema 和前端类型增加 `authProxy`。 +- [x] 管理列表及 CRUD 权限收敛到创建成员。 +- [x] 创建/更新接口增加 owner-only `authProxy` 校验。 +- [x] 实现代理身份解析和执行时应用权限校验。 +- [x] Streamable HTTP 与 SSE 转发代理请求头。 +- [x] 发布表单增加 owner-only 身份代理开关及多语言文案。 +- [x] 补充局部测试并运行类型检查。 diff --git a/packages/global/openapi/support/mcpServer/api.ts b/packages/global/openapi/support/mcpServer/api.ts index e533976f5af0..5e49268ddaf3 100644 --- a/packages/global/openapi/support/mcpServer/api.ts +++ b/packages/global/openapi/support/mcpServer/api.ts @@ -45,6 +45,7 @@ export const McpListResponseItemSchema = z.object({ key: z.string().meta({ example: 'abcDEF123...', description: 'MCP Server 访问密钥' }), teamId: ObjectIdSchema.meta({ description: '团队 ID' }), tmbId: ObjectIdSchema.meta({ description: '团队成员 ID' }), + authProxy: z.boolean().default(false).meta({ description: '是否允许调用方代理团队成员身份' }), apps: z.array(McpAppSchema).meta({ description: '应用工具列表' }) }); export const McpListResponseSchema = z.array(McpListResponseItemSchema); @@ -57,6 +58,9 @@ export type McpListResponseType = z.infer; export const McpCreateBodySchema = z.object({ name: McpNameSchema, + authProxy: z.boolean().default(false).meta({ + description: '是否允许调用方代理团队成员身份,仅团队所有者可开启' + }), apps: McpAppsBodySchema }); export type McpCreateBodyType = z.infer; @@ -72,6 +76,9 @@ export type McpCreateResponseType = z.infer; export const McpUpdateBodySchema = z.object({ id: ObjectIdSchema.meta({ description: 'MCP Server ID' }), name: McpNameSchema.optional(), + authProxy: z.boolean().optional().meta({ + description: '是否允许调用方代理团队成员身份,仅团队所有者可开启' + }), apps: McpAppsBodySchema }); export type McpUpdateBodyType = z.infer; @@ -91,3 +98,39 @@ export type McpDeleteQueryType = z.infer; export const McpDeleteResponseSchema = z.undefined().meta({ description: '删除成功' }); export type McpDeleteResponseType = z.infer; + +/* ============================================================================ + * API: 获取已发布 MCP Server 的工具列表 + * Route: GET /api/support/mcp/server/toolList + * ============================================================================ */ + +export const McpToolListQuerySchema = z.object({ + key: z.string().min(1).meta({ description: 'MCP Server 发布密钥' }) +}); + +/* ============================================================================ + * API: 调用已发布 MCP Server 的工具 + * Route: POST /api/support/mcp/server/toolCall + * ============================================================================ */ + +export const McpAuthProxySchema = z + .object({ + username: z.string().trim().min(1).max(128).optional().meta({ + example: 'user@example.com', + description: '代理调用的团队成员用户名' + }), + tmbId: ObjectIdSchema.optional().meta({ + description: '代理调用的团队成员 ID' + }) + }) + .strict() + .refine(({ username, tmbId }) => !!username || !!tmbId, { + message: 'authProxy.username or authProxy.tmbId is required' + }); +export type McpAuthProxyType = z.infer; + +export const McpToolCallBodySchema = z.object({ + key: z.string().min(1).meta({ description: 'MCP Server 发布密钥' }), + toolName: z.string().min(1).meta({ description: '要调用的工具名称' }), + inputs: z.record(z.string(), z.any()).meta({ description: '工具调用参数' }) +}); diff --git a/packages/global/support/mcp/type.ts b/packages/global/support/mcp/type.ts index f2d0b7770614..ac93cfa3ab05 100644 --- a/packages/global/support/mcp/type.ts +++ b/packages/global/support/mcp/type.ts @@ -5,6 +5,7 @@ export type McpKeyType = { tmbId: string; apps: McpAppType[]; name: string; + authProxy: boolean; }; export type McpAppType = { @@ -13,3 +14,8 @@ export type McpAppType = { toolName: string; description: string; }; + +export const McpAuthProxyHeader = { + username: 'x-fastgpt-auth-proxy-username', + tmbId: 'x-fastgpt-auth-proxy-tmb-id' +} as const; diff --git a/packages/service/support/mcp/schema.ts b/packages/service/support/mcp/schema.ts index 86f0d8f21cc3..d1610fce2a19 100644 --- a/packages/service/support/mcp/schema.ts +++ b/packages/service/support/mcp/schema.ts @@ -30,6 +30,10 @@ const McpKeySchema = new Schema({ ref: TeamMemberCollectionName, required: true }, + authProxy: { + type: Boolean, + default: false + }, apps: { type: [ { diff --git a/packages/service/support/permission/mcp/auth.ts b/packages/service/support/permission/mcp/auth.ts index 618a50d29c25..3411a70e0956 100644 --- a/packages/service/support/permission/mcp/auth.ts +++ b/packages/service/support/permission/mcp/auth.ts @@ -30,7 +30,8 @@ export const authMcp = async ({ return Promise.reject(TeamErrEnum.unPermission); } - if (!permission.hasManagePer && !isRoot && tmbId !== String(mcp.tmbId)) { + // MCP 发布项与个人 APIKey 一样只归创建成员管理。 + if (tmbId !== String(mcp.tmbId)) { return Promise.reject(TeamErrEnum.unPermission); } diff --git a/packages/web/i18n/en/dashboard_mcp.json b/packages/web/i18n/en/dashboard_mcp.json index b1014edae530..c340951edf20 100644 --- a/packages/web/i18n/en/dashboard_mcp.json +++ b/packages/web/i18n/en/dashboard_mcp.json @@ -2,6 +2,8 @@ "app_description": "Application Description", "app_name": "Application name", "apps": "Exposed applications", + "auth_proxy": "Auth proxy", + "auth_proxy_tip": "Allow callers to run tools as a team member through request headers. Only team owners can enable it.", "create_mcp": "Create an MCP service", "create_mcp_server": "Create a new service", "delete_mcp_server_confirm_tip": "Confirm to delete the service?", diff --git a/packages/web/i18n/zh-CN/dashboard_mcp.json b/packages/web/i18n/zh-CN/dashboard_mcp.json index 2a1167bdb827..90f4e90fe776 100644 --- a/packages/web/i18n/zh-CN/dashboard_mcp.json +++ b/packages/web/i18n/zh-CN/dashboard_mcp.json @@ -2,6 +2,8 @@ "app_description": "应用描述", "app_name": "应用名", "apps": "暴露的应用", + "auth_proxy": "身份代理", + "auth_proxy_tip": "允许调用方通过请求头代理团队成员身份执行工具。仅团队所有者可开启。", "create_mcp": "创建 MCP 服务", "create_mcp_server": "新建服务", "delete_mcp_server_confirm_tip": "确认删除该服务?", diff --git a/packages/web/i18n/zh-Hant/dashboard_mcp.json b/packages/web/i18n/zh-Hant/dashboard_mcp.json index ffe556319035..c27057ffc3b8 100644 --- a/packages/web/i18n/zh-Hant/dashboard_mcp.json +++ b/packages/web/i18n/zh-Hant/dashboard_mcp.json @@ -2,6 +2,8 @@ "app_description": "應用描述", "app_name": "應用名", "apps": "暴露的應用", + "auth_proxy": "身份代理", + "auth_proxy_tip": "允許調用方透過請求標頭代理團隊成員身份執行工具。僅團隊擁有者可開啟。", "create_mcp": "創建 MCP 服務", "create_mcp_server": "新建服務", "delete_mcp_server_confirm_tip": "確認刪除該服務?", diff --git a/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx b/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx index a5ccddfc06a7..e2a510915c52 100644 --- a/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx +++ b/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx @@ -9,6 +9,7 @@ import { Input, ModalBody, ModalFooter, + Switch, Table, TableContainer, Tbody, @@ -21,7 +22,7 @@ import { import MyModal from '@fastgpt/web/components/common/MyModal'; import { type McpAppType } from '@fastgpt/global/support/mcp/type'; import { useTranslation } from 'next-i18next'; -import { useFieldArray, useForm } from 'react-hook-form'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; @@ -36,15 +37,18 @@ import { AppFolderTypeList } from '@fastgpt/global/core/app/constants'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { postCreateMcpServer, putUpdateMcpServer } from '../../../web/support/mcp/api'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import { useUserStore } from '@/web/support/user/useUserStore'; export type EditMcForm = { id?: string; name: string; + authProxy: boolean; apps: McpAppType[]; }; export const defaultForm: EditMcForm = { name: '', + authProxy: false, apps: [] }; @@ -257,6 +261,7 @@ const EditMcpModal = ({ onSuccess: () => void; }) => { const { t } = useTranslation(); + const { userInfo } = useUserStore(); const isEdit = !!editMcp.id; const { isOpen: isOpenSelectApp, @@ -281,6 +286,7 @@ const EditMcpModal = ({ (data: EditMcForm) => postCreateMcpServer({ name: data.name, + authProxy: data.authProxy, apps: data.apps.map((item) => ({ appId: item.appId, toolName: item.toolName, @@ -299,6 +305,7 @@ const EditMcpModal = ({ putUpdateMcpServer({ id: data.id!, name: data.name, + authProxy: data.authProxy, apps: data.apps.map((item) => ({ appId: item.appId, toolName: item.toolName, @@ -331,6 +338,23 @@ const EditMcpModal = ({ + + + {t('dashboard_mcp:auth_proxy')} + + + ( + field.onChange(event.target.checked)} + /> + )} + /> + {t('dashboard_mcp:apps')} diff --git a/projects/app/src/pages/api/mcp/app/[key]/mcp.ts b/projects/app/src/pages/api/mcp/app/[key]/mcp.ts index 215418e1579a..6eac45c7e8de 100644 --- a/projects/app/src/pages/api/mcp/app/[key]/mcp.ts +++ b/projects/app/src/pages/api/mcp/app/[key]/mcp.ts @@ -10,6 +10,7 @@ import { import { callMcpServerTool, getMcpServerTools } from '@/service/support/mcp/utils'; import { type toolCallProps } from '@/service/support/mcp/type'; import { getErrText } from '@fastgpt/global/common/error/utils'; +import { getMcpAuthProxyFromHeaders } from '@/service/support/mcp/auth'; const logger = getLogger(LogCategories.MODULE.MCP.APP); export type mcpQuery = { key: string }; @@ -52,7 +53,8 @@ const handlePost = async (req: ApiRequestProps, res: ApiRespo ): Promise => { try { logger.debug(`Call tool: ${name} with args: ${JSON.stringify(args)}`); - const result = await callMcpServerTool({ key, toolName: name, inputs: args }); + const authProxy = getMcpAuthProxyFromHeaders(req.headers); + const result = await callMcpServerTool({ key, toolName: name, inputs: args, authProxy }); return { content: [ diff --git a/projects/app/src/pages/api/support/mcp/create.ts b/projects/app/src/pages/api/support/mcp/create.ts index 853650b5f3be..704fbdab3683 100644 --- a/projects/app/src/pages/api/support/mcp/create.ts +++ b/projects/app/src/pages/api/support/mcp/create.ts @@ -23,10 +23,14 @@ async function handler(req: ApiRequestProps): Promise { return Promise.reject(TeamErrEnum.unPermission); } - const { name, apps } = parseApiInput({ req, bodySchema: McpCreateBodySchema }).body; + const { name, apps, authProxy } = parseApiInput({ req, bodySchema: McpCreateBodySchema }).body; + + if (authProxy && !permission.isOwner) { + return Promise.reject(TeamErrEnum.unPermission); + } // Count mcp length - const totalMcp = await MongoMcpKey.countDocuments({ teamId }); + const totalMcp = await MongoMcpKey.countDocuments({ teamId, tmbId }); if (totalMcp >= 100) { return Promise.reject('暂时只支持100个MCP服务'); } @@ -56,6 +60,7 @@ async function handler(req: ApiRequestProps): Promise { teamId, tmbId, name, + authProxy, apps: uniqueApps }); diff --git a/projects/app/src/pages/api/support/mcp/list.ts b/projects/app/src/pages/api/support/mcp/list.ts index 8dd3344b9c84..dfd34df56b5f 100644 --- a/projects/app/src/pages/api/support/mcp/list.ts +++ b/projects/app/src/pages/api/support/mcp/list.ts @@ -11,18 +11,13 @@ async function handler( req: ApiRequestProps, _res: ApiResponseType ): Promise { - const { teamId, tmbId, permission } = await authUserPer({ + const { teamId, tmbId } = await authUserPer({ req, authToken: true, authApiKey: true }); - const list = await (async () => { - if (permission.hasManagePer) { - return await MongoMcpKey.find({ teamId }).lean().sort({ _id: -1 }); - } - return await MongoMcpKey.find({ teamId, tmbId }).lean().sort({ _id: -1 }); - })(); + const list = await MongoMcpKey.find({ teamId, tmbId }).lean().sort({ _id: -1 }); return McpListResponseSchema.parse(list); } diff --git a/projects/app/src/pages/api/support/mcp/server/toolCall.ts b/projects/app/src/pages/api/support/mcp/server/toolCall.ts index bb418e92ab87..65f13e2dbb77 100644 --- a/projects/app/src/pages/api/support/mcp/server/toolCall.ts +++ b/projects/app/src/pages/api/support/mcp/server/toolCall.ts @@ -2,6 +2,9 @@ import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { type toolCallProps } from '@/service/support/mcp/type'; import { callMcpServerTool } from '@/service/support/mcp/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { McpToolCallBodySchema } from '@fastgpt/global/openapi/support/mcpServer/api'; +import { getMcpAuthProxyFromHeaders } from '@/service/support/mcp/auth'; export type toolCallQuery = Record; @@ -13,7 +16,11 @@ async function handler( req: ApiRequestProps, _res: ApiResponseType ): Promise { - return callMcpServerTool(req.body); + const body = parseApiInput({ req, bodySchema: McpToolCallBodySchema }).body; + return callMcpServerTool({ + ...body, + authProxy: getMcpAuthProxyFromHeaders(req.headers) + }); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/support/mcp/server/toolList.ts b/projects/app/src/pages/api/support/mcp/server/toolList.ts index 0f732a696dae..eb48bafee1e5 100644 --- a/projects/app/src/pages/api/support/mcp/server/toolList.ts +++ b/projects/app/src/pages/api/support/mcp/server/toolList.ts @@ -2,6 +2,8 @@ import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { type Tool } from '@modelcontextprotocol/sdk/types.js'; import { getMcpServerTools } from '@/service/support/mcp/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { McpToolListQuerySchema } from '@fastgpt/global/openapi/support/mcpServer/api'; export type listToolsQuery = { key: string }; @@ -11,7 +13,7 @@ async function handler( req: ApiRequestProps, _res: ApiResponseType ): Promise { - const { key } = req.query; + const { key } = parseApiInput({ req, querySchema: McpToolListQuerySchema }).query; return getMcpServerTools(key); } diff --git a/projects/app/src/pages/api/support/mcp/update.ts b/projects/app/src/pages/api/support/mcp/update.ts index c1704054ce51..a68a784fda4d 100644 --- a/projects/app/src/pages/api/support/mcp/update.ts +++ b/projects/app/src/pages/api/support/mcp/update.ts @@ -10,10 +10,19 @@ import { McpUpdateResponseSchema, type McpUpdateResponseType } from '@fastgpt/global/openapi/support/mcpServer/api'; +import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; async function handler(req: ApiRequestProps): Promise { - const { id: mcpId, name, apps } = parseApiInput({ req, bodySchema: McpUpdateBodySchema }).body; - const { tmbId } = await authMcp({ + const { + id: mcpId, + name, + apps, + authProxy + } = parseApiInput({ + req, + bodySchema: McpUpdateBodySchema + }).body; + const { tmbId, permission } = await authMcp({ req, authToken: true, authApiKey: true, @@ -21,6 +30,10 @@ async function handler(req: ApiRequestProps): Promise { per: WritePermissionVal }); + if (authProxy && !permission.isOwner) { + return Promise.reject(TeamErrEnum.unPermission); + } + // 对 apps 中的 id 进行去重,确保每个应用只出现一次 const uniqueAppIds = new Set(); const uniqueApps = apps.filter((app) => { @@ -47,6 +60,7 @@ async function handler(req: ApiRequestProps): Promise { { $set: { ...(name && { name }), + ...(authProxy !== undefined && { authProxy }), apps: uniqueApps } } diff --git a/projects/app/src/pages/dashboard/mcpServer/index.tsx b/projects/app/src/pages/dashboard/mcpServer/index.tsx index dd517059eded..04c291099e4b 100644 --- a/projects/app/src/pages/dashboard/mcpServer/index.tsx +++ b/projects/app/src/pages/dashboard/mcpServer/index.tsx @@ -136,6 +136,7 @@ const McpServer = () => { setEditMcp({ id: mcp._id, name: mcp.name, + authProxy: mcp.authProxy, apps: mcp.apps }) } diff --git a/projects/app/src/service/support/mcp/auth.ts b/projects/app/src/service/support/mcp/auth.ts new file mode 100644 index 000000000000..85d58f8d2a1f --- /dev/null +++ b/projects/app/src/service/support/mcp/auth.ts @@ -0,0 +1,95 @@ +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import { + McpAuthProxySchema, + type McpAuthProxyType +} from '@fastgpt/global/openapi/support/mcpServer/api'; +import { McpAuthProxyHeader, type McpKeyType } from '@fastgpt/global/support/mcp/type'; +import { notLeaveStatus } from '@fastgpt/global/support/user/team/constant'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; + +type HeaderValue = string | string[] | undefined; + +/** 从 MCP transport 请求头中解析可选的身份代理参数。 */ +export const getMcpAuthProxyFromHeaders = ( + headers: Record +): McpAuthProxyType | undefined => { + const getHeader = (name: string) => { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; + }; + + const username = getHeader(McpAuthProxyHeader.username); + const tmbId = getHeader(McpAuthProxyHeader.tmbId); + if (!username && !tmbId) return undefined; + + return McpAuthProxySchema.parse({ username, tmbId }); +}; + +/** + * 解析 MCP 工具调用最终应归属的团队成员。 + * + * 未提供代理身份时使用发布者;提供代理身份时要求发布项已开启 authProxy,且目标成员 + * 仍在发布项所属团队。username 与 tmbId 同时存在时必须指向同一成员。 + */ +export const resolveMcpEffectiveTmbId = async ({ + mcp, + authProxy +}: { + mcp: Pick; + authProxy?: McpAuthProxyType; +}) => { + if (!authProxy) { + return String(mcp.tmbId); + } + + if (!mcp.authProxy) { + return Promise.reject(ERROR_ENUM.unAuthorization); + } + + const username = authProxy.username?.trim(); + const [memberByTmbId, memberByUsername] = await Promise.all([ + authProxy.tmbId + ? MongoTeamMember.findOne({ + _id: authProxy.tmbId, + teamId: mcp.teamId, + status: notLeaveStatus + }) + .select('_id') + .lean() + : null, + username + ? (async () => { + const user = await MongoUser.findOne({ username }).select('_id').lean(); + if (!user) return null; + + return MongoTeamMember.findOne({ + teamId: mcp.teamId, + userId: user._id, + status: notLeaveStatus + }) + .select('_id') + .lean(); + })() + : null + ]); + + if ((authProxy.tmbId && !memberByTmbId) || (username && !memberByUsername)) { + return Promise.reject(ERROR_ENUM.unAuthorization); + } + + if ( + memberByTmbId && + memberByUsername && + String(memberByTmbId._id) !== String(memberByUsername._id) + ) { + return Promise.reject(ERROR_ENUM.unAuthorization); + } + + const member = memberByTmbId || memberByUsername; + if (!member) { + return Promise.reject(ERROR_ENUM.unAuthorization); + } + + return String(member._id); +}; diff --git a/projects/app/src/service/support/mcp/type.ts b/projects/app/src/service/support/mcp/type.ts index b65f91c4bd6a..9127630e391a 100644 --- a/projects/app/src/service/support/mcp/type.ts +++ b/projects/app/src/service/support/mcp/type.ts @@ -1,5 +1,8 @@ +import type { McpAuthProxyType } from '@fastgpt/global/openapi/support/mcpServer/api'; + export type toolCallProps = { key: string; toolName: string; inputs: Record; + authProxy?: McpAuthProxyType; }; diff --git a/projects/app/src/service/support/mcp/utils.ts b/projects/app/src/service/support/mcp/utils.ts index 613714fae597..dc259578b062 100644 --- a/projects/app/src/service/support/mcp/utils.ts +++ b/projects/app/src/service/support/mcp/utils.ts @@ -42,6 +42,9 @@ import { preChatRound } from '@fastgpt/service/core/chat/utils/prepare'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import { getRuntimeNodeResponseSummary } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { resolveMcpEffectiveTmbId } from './auth'; const stringifyMcpPluginOutput = (pluginOutput: unknown) => { if (pluginOutput === undefined || pluginOutput === null) { @@ -176,11 +179,15 @@ export const getMcpServerTools = async (key: string): Promise => { /** * 调用 MCP key 已绑定的工具。 * - * 这里延续 MCP key 的绑定快照语义:调用时只校验 key 和 toolName 是否存在于绑定关系中, - * 不根据创建人的实时应用权限再次拒绝执行;如需撤销 MCP 访问,应更新或删除对应 MCP key。 + * 发布 key 用于定位绑定关系;每次执行都按发布者或 authProxy 代理成员重新校验应用读权限, + * 并将对话与运行用户上下文归属到该有效成员。 */ -export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps) => { - const dispatchApp = async (app: AppSchemaType, variables: Record) => { +export const callMcpServerTool = async ({ key, toolName, inputs, authProxy }: toolCallProps) => { + const dispatchApp = async ( + app: AppSchemaType, + variables: Record, + effectiveTmbId: string + ) => { const isPlugin = app.type === AppTypeEnum.workflowTool; const pluginFixedTitle = isPlugin ? 'Mcp call' : undefined; @@ -247,7 +254,7 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps ...chatSource, chatId, teamId: String(app.teamId), - tmbId: String(app.tmbId), + tmbId: effectiveTmbId, source: ChatSourceEnum.mcp, userContent: workflowUserQuestion, responseChatItemId, @@ -274,8 +281,8 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps teamId: String(app.teamId), tmbId: String(app.tmbId) }, - runningUserInfo: await getRunningUserInfoByTmbId(app.tmbId), - uid: String(app.tmbId), + runningUserInfo: await getRunningUserInfoByTmbId(effectiveTmbId), + uid: effectiveTmbId, runtimeNodes, runtimeEdges: storeEdges2RuntimeEdges(edges), variables, @@ -305,7 +312,7 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps chatId: preparedRound.chatId, versionId, teamId: String(app.teamId), - tmbId: String(app.tmbId), + tmbId: effectiveTmbId, nodes, appChatConfig: chatConfig, variables: newVariables, @@ -350,7 +357,10 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps } }; - const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean(); + const mcp = await MongoMcpKey.findOne( + { key }, + { apps: 1, teamId: 1, tmbId: 1, authProxy: 1 } + ).lean(); if (!mcp) { return Promise.reject(CommonErrEnum.invalidResource); @@ -374,5 +384,12 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps return Promise.reject(CommonErrEnum.missingParams); } - return await dispatchApp(app, inputs); + const effectiveTmbId = await resolveMcpEffectiveTmbId({ mcp, authProxy }); + await authAppByTmbId({ + tmbId: effectiveTmbId, + appId: String(app._id), + per: ReadPermissionVal + }); + + return await dispatchApp(app, inputs, effectiveTmbId); }; diff --git a/projects/app/test/api/support/mcp/publish.test.ts b/projects/app/test/api/support/mcp/publish.test.ts new file mode 100644 index 000000000000..5640404fbb5a --- /dev/null +++ b/projects/app/test/api/support/mcp/publish.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import createHandler from '@/pages/api/support/mcp/create'; +import listHandler from '@/pages/api/support/mcp/list'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { TeamApikeyCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema'; +import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { getFakeUsers } from '@test/datas/users'; +import { Call } from '@test/utils/request'; + +describe('support/mcp publish management', () => { + it('returns only MCP publications created by the current member', async () => { + const { manager, members } = await getFakeUsers(1); + const [member] = members; + await MongoMcpKey.create([ + { + teamId: manager.teamId, + tmbId: manager.tmbId, + name: 'manager mcp', + apps: [] + }, + { + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member mcp', + apps: [] + } + ]); + + const result = await Call(listHandler, { auth: manager }); + + expect(result.code).toBe(200); + expect(result.data).toHaveLength(1); + expect(result.data[0].name).toBe('manager mcp'); + expect(result.data[0].authProxy).toBe(false); + }); + + it('allows a team owner to publish MCP with authProxy enabled', async () => { + const { owner } = await getFakeUsers(1); + const app = await MongoApp.create({ + teamId: owner.teamId, + tmbId: owner.tmbId, + name: 'owner app', + type: AppTypeEnum.simple + }); + + const result = await Call(createHandler, { + auth: owner, + body: { + name: 'owner proxy mcp', + authProxy: true, + apps: [ + { + appId: String(app._id), + appName: app.name, + toolName: 'owner_tool', + description: 'Owner tool' + } + ] + } + }); + + expect(result.code).toBe(200); + expect( + await MongoMcpKey.findOne({ + teamId: owner.teamId, + tmbId: owner.tmbId, + name: 'owner proxy mcp', + authProxy: true + }) + ).not.toBeNull(); + }); + + it('rejects authProxy when a non-owner publishes MCP', async () => { + const { members } = await getFakeUsers(1); + const [member] = members; + await MongoResourcePermission.create({ + resourceType: 'team', + teamId: member.teamId, + resourceId: null, + tmbId: member.tmbId, + permission: TeamApikeyCreatePermissionVal + }); + const app = await MongoApp.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member app', + type: AppTypeEnum.simple + }); + + const result = await Call(createHandler, { + auth: member, + body: { + name: 'member proxy mcp', + authProxy: true, + apps: [ + { + appId: String(app._id), + appName: app.name, + toolName: 'member_tool', + description: 'Member tool' + } + ] + } + }); + + expect(result.code).toBe(500); + expect(await MongoMcpKey.findOne({ name: 'member proxy mcp' })).toBeNull(); + }); +}); diff --git a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts index 3528b2c6156f..cd4b43438228 100644 --- a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts +++ b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts @@ -17,6 +17,7 @@ import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch'; import { failChatRound, finalizeChatRound } from '@fastgpt/service/core/chat/saveChat'; import { preChatRound } from '@fastgpt/service/core/chat/utils/prepare'; import { getRunningUserInfoByTmbId } from '@fastgpt/service/support/user/team/utils'; +import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth'; vi.mock('@fastgpt/service/support/mcp/schema', () => ({ MongoMcpKey: { @@ -181,6 +182,9 @@ describe('callMcpServerTool', () => { it('returns workflowTool pluginOutput using the same value source as main', async () => { vi.mocked(MongoMcpKey.findOne).mockReturnValue({ lean: () => ({ + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: false, apps: [ { appId: 'app-id', @@ -255,15 +259,24 @@ describe('callMcpServerTool', () => { }) ).resolves.toBe(JSON.stringify({ result: 'plugin output value' })); + expect(authAppByTmbId).toHaveBeenCalledWith({ + tmbId: 'publisher-tmb-id', + appId: 'app-id', + per: expect.any(Number) + }); + expect(getRunningUserInfoByTmbId).toHaveBeenCalledWith('publisher-tmb-id'); + expect(dispatchWorkFlow).toHaveBeenCalledWith( expect.objectContaining({ chatId: 'prepared-mcp-chat-id', - responseChatItemId: 'prepared-mcp-response-id' + responseChatItemId: 'prepared-mcp-response-id', + uid: 'publisher-tmb-id' }) ); expect(finalizeChatRound).toHaveBeenCalledWith( expect.objectContaining({ chatId: 'prepared-mcp-chat-id', + tmbId: 'publisher-tmb-id', aiContent: expect.objectContaining({ dataId: 'prepared-mcp-response-id' }) diff --git a/projects/app/test/service/support/mcp/auth.test.ts b/projects/app/test/service/support/mcp/auth.test.ts new file mode 100644 index 000000000000..3eab9092d0b5 --- /dev/null +++ b/projects/app/test/service/support/mcp/auth.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import { McpAuthProxyHeader } from '@fastgpt/global/support/mcp/type'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { getMcpAuthProxyFromHeaders, resolveMcpEffectiveTmbId } from '@/service/support/mcp/auth'; + +vi.mock('@fastgpt/service/support/user/schema', () => ({ + MongoUser: { + findOne: vi.fn() + } +})); + +vi.mock('@fastgpt/service/support/user/team/teamMemberSchema', () => ({ + MongoTeamMember: { + findOne: vi.fn() + } +})); + +const mockLeanQuery = (value: unknown) => ({ + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(value) + }) +}); + +describe('MCP auth proxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('parses optional proxy identity from transport headers', () => { + expect(getMcpAuthProxyFromHeaders({})).toBeUndefined(); + expect( + getMcpAuthProxyFromHeaders({ + [McpAuthProxyHeader.username]: 'user@example.com', + [McpAuthProxyHeader.tmbId]: '68ad85a7463006c963799a05' + }) + ).toEqual({ + username: 'user@example.com', + tmbId: '68ad85a7463006c963799a05' + }); + }); + + it('uses publisher identity when no proxy is requested', async () => { + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: false + } + }) + ).resolves.toBe('publisher-tmb-id'); + + expect(MongoTeamMember.findOne).not.toHaveBeenCalled(); + }); + + it('rejects proxy identity when the publisher did not enable authProxy', async () => { + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: false + }, + authProxy: { tmbId: '68ad85a7463006c963799a05' } + }) + ).rejects.toBe(ERROR_ENUM.unAuthorization); + }); + + it('resolves an active member in the publishing team by tmbId', async () => { + vi.mocked(MongoTeamMember.findOne).mockReturnValue( + mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any + ); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { tmbId: '68ad85a7463006c963799a05' } + }) + ).resolves.toBe('68ad85a7463006c963799a05'); + + expect(MongoTeamMember.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + _id: '68ad85a7463006c963799a05', + teamId: 'team-id' + }) + ); + }); + + it('rejects when username and tmbId resolve to different members', async () => { + vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any); + vi.mocked(MongoTeamMember.findOne) + .mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any) + .mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a06' }) as any); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { + username: 'user@example.com', + tmbId: '68ad85a7463006c963799a05' + } + }) + ).rejects.toBe(ERROR_ENUM.unAuthorization); + }); +}); diff --git a/projects/mcp_server/src/api/fastgpt.ts b/projects/mcp_server/src/api/fastgpt.ts index f04cf9d2efbc..191071d4160b 100644 --- a/projects/mcp_server/src/api/fastgpt.ts +++ b/projects/mcp_server/src/api/fastgpt.ts @@ -1,7 +1,16 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { GET, POST } from './request'; +import { McpAuthProxyHeader } from '@fastgpt/global/support/mcp/type'; export const getTools = (key: string) => GET('/support/mcp/server/toolList', { key }); -export const callTool = (data: { key: string; toolName: string; inputs: Record }) => - POST('/support/mcp/server/toolCall', data); +export const callTool = ( + data: { key: string; toolName: string; inputs: Record }, + authProxy?: { username?: string; tmbId?: string } +) => + POST('/support/mcp/server/toolCall', data, { + headers: { + ...(authProxy?.username && { [McpAuthProxyHeader.username]: authProxy.username }), + ...(authProxy?.tmbId && { [McpAuthProxyHeader.tmbId]: authProxy.tmbId }) + } + }); diff --git a/projects/mcp_server/src/index.ts b/projects/mcp_server/src/index.ts index ec3af3ea10ca..9ee3698126fb 100644 --- a/projects/mcp_server/src/index.ts +++ b/projects/mcp_server/src/index.ts @@ -10,6 +10,7 @@ import { callTool, getTools } from './api/fastgpt'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { configureLogger, getLogger, LogCategories } from './logger'; import { mcpServerEnv } from './env'; +import { McpAuthProxyHeader } from '@fastgpt/global/support/mcp/type'; const app = express(); const logger = getLogger(LogCategories.MODULE.MCP.SERVER); @@ -18,6 +19,10 @@ const transportMap: Record = {}; app.get('/:key/sse', async (req, res) => { const { key } = req.params; + const authProxy = { + username: req.header(McpAuthProxyHeader.username), + tmbId: req.header(McpAuthProxyHeader.tmbId) + }; const transport = new SSEServerTransport(`/${key}/messages`, res); @@ -61,7 +66,7 @@ app.get('/:key/sse', async (req, res) => { ): Promise => { try { logger.info(`Call tool: ${name} with args: ${JSON.stringify(args)}`); - const result = await callTool({ key, toolName: name, inputs: args }); + const result = await callTool({ key, toolName: name, inputs: args }, authProxy); return { content: [ From 94c9ee54674332cc38ba46acdb3c554fb705f3b9 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 23 Jul 2026 18:07:45 +0800 Subject: [PATCH 2/3] test(mcp): cover publish auth proxy permissions --- .../app/test/api/support/mcp/publish.test.ts | 129 ++++++++++++++++++ .../api/support/mcp/server/toolList.test.ts | 50 ++++++- .../app/test/service/support/mcp/auth.test.ts | 77 +++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/projects/app/test/api/support/mcp/publish.test.ts b/projects/app/test/api/support/mcp/publish.test.ts index 5640404fbb5a..fda2ad594729 100644 --- a/projects/app/test/api/support/mcp/publish.test.ts +++ b/projects/app/test/api/support/mcp/publish.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import createHandler from '@/pages/api/support/mcp/create'; +import deleteHandler from '@/pages/api/support/mcp/delete'; import listHandler from '@/pages/api/support/mcp/list'; +import updateHandler from '@/pages/api/support/mcp/update'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { TeamApikeyCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; import { MongoApp } from '@fastgpt/service/core/app/schema'; @@ -108,4 +110,131 @@ describe('support/mcp publish management', () => { expect(result.code).toBe(500); expect(await MongoMcpKey.findOne({ name: 'member proxy mcp' })).toBeNull(); }); + + it('allows the owner to enable authProxy on their MCP publication', async () => { + const { owner } = await getFakeUsers(1); + const app = await MongoApp.create({ + teamId: owner.teamId, + tmbId: owner.tmbId, + name: 'owner update app', + type: AppTypeEnum.simple + }); + const mcp = await MongoMcpKey.create({ + teamId: owner.teamId, + tmbId: owner.tmbId, + name: 'owner update mcp', + apps: [] + }); + + const result = await Call(updateHandler, { + auth: owner, + body: { + id: String(mcp._id), + authProxy: true, + apps: [ + { + appId: String(app._id), + appName: app.name, + toolName: 'owner_update_tool', + description: 'Owner update tool' + } + ] + } + }); + + expect(result.code).toBe(200); + expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(true); + }); + + it('rejects enabling authProxy when a non-owner updates their MCP publication', async () => { + const { members } = await getFakeUsers(1); + const [member] = members; + const app = await MongoApp.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member update app', + type: AppTypeEnum.simple + }); + const mcp = await MongoMcpKey.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member update mcp', + apps: [] + }); + + const result = await Call(updateHandler, { + auth: member, + body: { + id: String(mcp._id), + authProxy: true, + apps: [ + { + appId: String(app._id), + appName: app.name, + toolName: 'member_update_tool', + description: 'Member update tool' + } + ] + } + }); + + expect(result.code).toBe(500); + expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(false); + }); + + it('allows a non-owner to disable an existing authProxy setting', async () => { + const { members } = await getFakeUsers(1); + const [member] = members; + const app = await MongoApp.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member disable app', + type: AppTypeEnum.simple + }); + const mcp = await MongoMcpKey.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member disable mcp', + authProxy: true, + apps: [] + }); + + const result = await Call(updateHandler, { + auth: member, + body: { + id: String(mcp._id), + authProxy: false, + apps: [ + { + appId: String(app._id), + appName: app.name, + toolName: 'member_disable_tool', + description: 'Member disable tool' + } + ] + } + }); + + expect(result.code).toBe(200); + expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(false); + }); + + it('does not allow a team owner to delete another member publication', async () => { + const { owner, members } = await getFakeUsers(1); + const [member] = members; + const mcp = await MongoMcpKey.create({ + teamId: member.teamId, + tmbId: member.tmbId, + name: 'member private mcp', + apps: [] + }); + + const result = await Call(deleteHandler, { + auth: owner, + query: { id: String(mcp._id) } + }); + + expect(result.code).toBe(500); + expect(await MongoMcpKey.findById(mcp._id)).not.toBeNull(); + }); }); diff --git a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts index cd4b43438228..4970b053c0a1 100644 --- a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts +++ b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { callMcpServerTool, pluginNodes2InputSchema, @@ -179,6 +179,10 @@ describe('toolList', () => { }); describe('callMcpServerTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('returns workflowTool pluginOutput using the same value source as main', async () => { vi.mocked(MongoMcpKey.findOne).mockReturnValue({ lean: () => ({ @@ -283,4 +287,48 @@ describe('callMcpServerTool', () => { }) ); }); + + it('does not dispatch when the effective member has no app read permission', async () => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: false, + apps: [ + { + appId: 'app-id', + toolName: 'private_tool', + description: 'private tool' + } + ] + }) + } as any); + vi.mocked(MongoApp.find).mockReturnValue({ + lean: () => [ + { + _id: 'app-id', + name: 'Private App', + type: AppTypeEnum.workflow, + teamId: 'team-id', + tmbId: 'app-owner-tmb-id' + } + ] + } as any); + vi.mocked(authAppByTmbId).mockRejectedValue(new Error('unAuthApp')); + + await expect( + callMcpServerTool({ + key: 'mcp-key', + toolName: 'private_tool', + inputs: {} + }) + ).rejects.toThrow('unAuthApp'); + + expect(authAppByTmbId).toHaveBeenCalledWith({ + tmbId: 'publisher-tmb-id', + appId: 'app-id', + per: expect.any(Number) + }); + expect(dispatchWorkFlow).not.toHaveBeenCalled(); + }); }); diff --git a/projects/app/test/service/support/mcp/auth.test.ts b/projects/app/test/service/support/mcp/auth.test.ts index 3eab9092d0b5..92146222f048 100644 --- a/projects/app/test/service/support/mcp/auth.test.ts +++ b/projects/app/test/service/support/mcp/auth.test.ts @@ -92,6 +92,83 @@ describe('MCP auth proxy', () => { ); }); + it('rejects a tmbId that does not resolve to an active team member', async () => { + vi.mocked(MongoTeamMember.findOne).mockReturnValue(mockLeanQuery(null) as any); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { tmbId: '68ad85a7463006c963799a05' } + }) + ).rejects.toBe(ERROR_ENUM.unAuthorization); + }); + + it('resolves an active member in the publishing team by username', async () => { + vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any); + vi.mocked(MongoTeamMember.findOne).mockReturnValue( + mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any + ); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { username: 'user@example.com' } + }) + ).resolves.toBe('68ad85a7463006c963799a05'); + + expect(MongoUser.findOne).toHaveBeenCalledWith({ username: 'user@example.com' }); + expect(MongoTeamMember.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'team-id', + userId: 'user-id' + }) + ); + }); + + it('rejects a username that does not resolve to an active team member', async () => { + vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery(null) as any); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { username: 'missing@example.com' } + }) + ).rejects.toBe(ERROR_ENUM.unAuthorization); + }); + + it('accepts username and tmbId when they resolve to the same member', async () => { + vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any); + vi.mocked(MongoTeamMember.findOne) + .mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any) + .mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any); + + await expect( + resolveMcpEffectiveTmbId({ + mcp: { + teamId: 'team-id', + tmbId: 'publisher-tmb-id', + authProxy: true + }, + authProxy: { + username: 'user@example.com', + tmbId: '68ad85a7463006c963799a05' + } + }) + ).resolves.toBe('68ad85a7463006c963799a05'); + }); + it('rejects when username and tmbId resolve to different members', async () => { vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any); vi.mocked(MongoTeamMember.findOne) From 895c461efaad93676c6851b0c58a3d9dd777c879 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 24 Jul 2026 15:26:13 +0800 Subject: [PATCH 3/3] docs(mcp): document identity proxy configuration Add bilingual setup, header reference, permission requirements, verification, and troubleshooting for published MCP servers. --- .../guide/build/publish/mcp_server.en.mdx | 70 ++++++++++++++++++- .../guide/build/publish/mcp_server.mdx | 70 ++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/document/content/guide/build/publish/mcp_server.en.mdx b/document/content/guide/build/publish/mcp_server.en.mdx index d3071b102f79..c1b1c899a9e0 100644 --- a/document/content/guide/build/publish/mcp_server.en.mdx +++ b/document/content/guide/build/publish/mcp_server.en.mdx @@ -11,7 +11,7 @@ MCP has two main components: Client and Server. The Client is the AI model consu FastGPT's MCP Server feature lets you select `multiple` applications built on FastGPT and expose them via MCP protocol for external consumption. -Currently, FastGPT's MCP Server uses the SSE transport protocol, with plans to migrate to `HTTP Streamable` in the future. +FastGPT supports the `Streamable HTTP` transport. Self-hosted deployments can also expose the compatible `SSE` transport through the standalone MCP Server service. ## Using MCP Server in FastGPT @@ -35,7 +35,7 @@ After creating an MCP Server, click `Start Using` to get the access URL. | -------------------------- | -------------------------- | | ![](/imgs/mcp_server4.png) | ![](/imgs/mcp_server5.png) | -#### 3. Use the MCP Server +### 3. Use the MCP Server Use the URL in any MCP-compatible client to call your FastGPT applications — for example, `Cursor` or `Cherry Studio`. Here's how to set it up in Cursor. @@ -54,6 +54,72 @@ After sending a question about `fastgpt`, you'll see Cursor invoke an MCP tool ( | -------------------------- | --------------------------- | | ![](/imgs/mcp_server9.png) | ![](/imgs/mcp_server10.png) | +## Configure Identity Proxy + +Identity proxy lets a caller select the team member who executes an MCP tool. FastGPT checks that member's read permission for the target application and attributes chat and runtime records to that member. Use it when a gateway or shared MCP client sends requests on behalf of different team members. + +### Prerequisites + +- Only a team owner can enable identity proxy for an MCP Server. +- The proxied user must be an active member of the same team and have read permission for the target application. +- The key in the published MCP URL is an execution credential. Do not commit it to public code or share it with unrelated users. + +### 1. Enable identity proxy + +Create or edit an MCP Server, turn on `Auth proxy` in the publishing settings, and save the configuration. + +When a request does not include an identity proxy header, the tool continues to run as the MCP Server publisher. + +### 2. Configure the proxy identity headers + +Pass the identity in the MCP transport headers, not in the tool arguments. FastGPT accepts these headers: + +| Header | Value | Description | +| ------------------------------- | -------------------------- | ---------------------------------------------------------------- | +| `x-fastgpt-auth-proxy-username` | Team member login username | Recommended; this is usually the member's login email address | +| `x-fastgpt-auth-proxy-tmb-id` | FastGPT team member ID | Use this when your system already stores FastGPT team member IDs | + +Either header is sufficient. If you provide both, they must resolve to the same team member. + +For MCP clients that support custom headers, add `headers` to the configuration copied in step 2. This example uses a `Streamable HTTP` URL and a login username: + +```json +{ + "mcpServers": { + "fastgpt": { + "url": "https://fastgpt.example.com/api/mcp/app//mcp", + "headers": { + "x-fastgpt-auth-proxy-username": "member@example.com" + } + } + } +} +``` + +To use a team member ID, replace `headers` with: + +```json +{ + "x-fastgpt-auth-proxy-tmb-id": "" +} +``` + +SSE URLs use the same headers. The SSE service captures the proxy identity when it establishes the connection, so reconnect after changing a header. `Streamable HTTP` reads the headers for each request. + +### 3. Verify the configuration + +Call a published tool from the MCP client. A successful call confirms that the proxied user is still an active team member and has read permission for the target application. + +If the tool list loads but a tool call returns an authorization error, check the following: + +1. Identity proxy is enabled for the MCP Server. +2. The username or team member ID in the request header is correct. +3. If both headers are present, they identify the same member. +4. The member is still active in the team that published the MCP Server. +5. The member has read permission for the application being called. + +The tool list exposes only metadata such as tool names and parameters. FastGPT rechecks team membership and application permission for every tool call. + ## Self-Hosted MCP Server Setup Self-hosted FastGPT deployments require version `v4.9.6` or higher to use MCP Server. diff --git a/document/content/guide/build/publish/mcp_server.mdx b/document/content/guide/build/publish/mcp_server.mdx index c742dc7baaf2..a1c9d3793d33 100644 --- a/document/content/guide/build/publish/mcp_server.mdx +++ b/document/content/guide/build/publish/mcp_server.mdx @@ -11,7 +11,7 @@ MCP 协议主要包含 Client 和 Server 两部分。简单来说,Client 是 FastGPT MCP Server 功能允许你选择 `多个` 在 FastGPT 上构建好的应用,以 MCP 协议对外提供调用 FastGPT 应用的能力。 -目前 FastGPT 提供的 MCP server 为 SSE 通信协议,未来将会替换成 `HTTP streamable`。 +FastGPT 支持 `Streamable HTTP` 协议;私有化部署还可以通过独立的 MCP Server 服务提供兼容的 `SSE` 协议。 ## FastGPT 使用 MCP server @@ -35,7 +35,7 @@ FastGPT MCP Server 功能允许你选择 `多个` 在 FastGPT 上构建好的应 | -------------------------- | -------------------------- | | ![](/imgs/mcp_server4.png) | ![](/imgs/mcp_server5.png) | -#### 3. 使用 MCP server +### 3. 使用 MCP server 可以在支持 MCP 协议的客户端使用这些地址,来调用 FastGPT 应用,例如:`Cursor`、`Cherry Studio`。下面以 Cursor 为例,介绍如何使用 MCP server。 @@ -54,6 +54,72 @@ FastGPT MCP Server 功能允许你选择 `多个` 在 FastGPT 上构建好的应 | -------------------------- | --------------------------- | | ![](/imgs/mcp_server9.png) | ![](/imgs/mcp_server10.png) | +## 配置身份代理 + +身份代理允许调用方指定团队成员来执行 MCP 工具。工具执行时会校验该成员对目标应用的读取权限,对话记录和运行记录也归属该成员。适合由网关或统一 MCP 客户端代表不同团队成员发起调用的场景。 + +### 前提条件 + +- 只有团队所有者可以为 MCP server 开启身份代理。 +- 被代理用户必须是当前团队内未离开的成员,并且拥有目标应用的读取权限。 +- MCP 发布地址中的 key 是执行凭证,请勿写入公开代码或发送给无关人员。 + +### 1. 开启身份代理 + +创建或编辑 MCP server,在发布配置中打开 `身份代理`,然后保存。 + +未携带身份代理请求头时,工具仍以 MCP server 发布者的身份执行。 + +### 2. 配置代理身份请求头 + +身份信息需要放在 MCP transport 请求头中,不要放入工具参数。支持以下请求头: + +| 请求头 | 值 | 说明 | +| ------------------------------- | -------------------- | ------------------------------ | +| `x-fastgpt-auth-proxy-username` | 团队成员的登录用户名 | 推荐使用,通常为成员的登录邮箱 | +| `x-fastgpt-auth-proxy-tmb-id` | FastGPT 团队成员 ID | 适合已经保存团队成员 ID 的系统 | + +两个请求头任选一个即可。同时提供时,必须指向同一团队成员。 + +支持自定义请求头的 MCP 客户端可以在第 2 步复制的配置中增加 `headers`。以下示例使用 `Streamable HTTP` 地址和登录用户名: + +```json +{ + "mcpServers": { + "fastgpt": { + "url": "https://fastgpt.example.com/api/mcp/app//mcp", + "headers": { + "x-fastgpt-auth-proxy-username": "member@example.com" + } + } + } +} +``` + +使用团队成员 ID 时,将 `headers` 替换为: + +```json +{ + "x-fastgpt-auth-proxy-tmb-id": "" +} +``` + +SSE 地址使用相同的请求头。SSE 服务会在建立连接时保存代理身份,因此修改请求头后需要断开并重新连接;`Streamable HTTP` 会逐次读取请求头。 + +### 3. 验证配置 + +在 MCP 客户端调用一个已发布工具。调用成功表示代理成员仍在当前团队,并且拥有目标应用的读取权限。 + +工具列表可以正常显示、工具执行却提示无权限时,依次检查: + +1. MCP server 是否已开启 `身份代理`。 +2. 请求头中的用户名或团队成员 ID 是否正确。 +3. 同时传入两个请求头时,它们是否对应同一成员。 +4. 该成员是否仍在发布 MCP server 的团队中。 +5. 该成员是否拥有被调用应用的读取权限。 + +工具列表只公开工具名称和参数等元数据;FastGPT 会在每次工具执行时重新校验成员状态和应用权限。 + ## 私有化部署 MCP server 问题 私有化部署版本的 FastGPT,需要升级到 `v4.9.6` 及以上版本才可使用 MCP server 功能。