diff --git a/web/app/components/tools/mcp/__tests__/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx index ad554d1f7deb15..90990ccb12120a 100644 --- a/web/app/components/tools/mcp/__tests__/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -30,6 +30,21 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +// Default: SSO off entirely. Tests that need the toggle visible flip +// `sso_enforced_for_signin = true` AND set the protocol to 'oidc' or +// 'oauth2'. Tests for the SAML gate set protocol = 'saml' to assert the +// toggle stays hidden even when sso_enforced_for_signin is true. +const mockSystemFeatures = vi.hoisted(() => ({ + sso_enforced_for_signin: false, + sso_enforced_for_signin_protocol: '' as 'oidc' | 'oauth2' | 'saml' | '', +})) +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({ + queryKey: ['mock-system-features'], + queryFn: async () => mockSystemFeatures, + }), +})) + describe('MCPModal', () => { beforeEach(() => { vi.clearAllMocks() @@ -43,6 +58,9 @@ describe('MCPModal', () => { }, }, }) + // useSuspenseQuery(systemFeaturesQueryOptions) reads from this key — + // pre-populate so the modal renders synchronously instead of suspending. + queryClient.setQueryData(['mock-system-features'], mockSystemFeatures) return ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children) } @@ -719,4 +737,132 @@ describe('MCPModal', () => { } }) }) + + // M3 — Forward-user-identity toggle (PR #36840). + describe('Forward-user-identity toggle', () => { + beforeEach(() => { + mockSystemFeatures.sso_enforced_for_signin = false + mockSystemFeatures.sso_enforced_for_signin_protocol = '' + }) + + // Helper: turn SSO on with a refresh-capable protocol so the toggle is + // visible. Use this for any test that needs the field rendered. + const enableRefreshCapableSSO = () => { + mockSystemFeatures.sso_enforced_for_signin = true + mockSystemFeatures.sso_enforced_for_signin_protocol = 'oidc' + } + + const fillRequiredFields = () => { + fireEvent.change( + screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder'), + { target: { value: 'https://example.com/mcp' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder'), + { target: { value: 'srv' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder'), + { target: { value: 'srv-id' } }, + ) + } + + it('does not render the toggle when SSO is not configured', () => { + mockSystemFeatures.sso_enforced_for_signin = false + render(, { wrapper: createWrapper() }) + expect(screen.queryByText('tools.mcp.modal.forwardUserIdentity')).not.toBeInTheDocument() + }) + + it('renders the toggle and helper tip when SSO is configured', () => { + enableRefreshCapableSSO() + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.forwardUserIdentity')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.modal.forwardUserIdentityTip')).toBeInTheDocument() + }) + + it('does not render the toggle when SSO protocol is SAML (no refresh model)', () => { + mockSystemFeatures.sso_enforced_for_signin = true + mockSystemFeatures.sso_enforced_for_signin_protocol = 'saml' + render(, { wrapper: createWrapper() }) + expect(screen.queryByText('tools.mcp.modal.forwardUserIdentity')).not.toBeInTheDocument() + }) + + it('renders the toggle when SSO protocol is OAuth2', () => { + mockSystemFeatures.sso_enforced_for_signin = true + mockSystemFeatures.sso_enforced_for_signin_protocol = 'oauth2' + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.forwardUserIdentity')).toBeInTheDocument() + }) + + it('submits identity_mode="off" by default (toggle off)', async () => { + enableRefreshCapableSSO() + const onConfirm = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + + fillRequiredFields() + fireEvent.click(screen.getByText('tools.mcp.modal.confirm')) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + identity_mode: 'off', + }), + ) + }) + }) + + it('submits identity_mode="idp_token" when toggle is flipped on', async () => { + enableRefreshCapableSSO() + const onConfirm = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + + fillRequiredFields() + const fwdSwitch = screen.getByRole('switch', { + name: 'tools.mcp.modal.forwardUserIdentity', + }) + fireEvent.click(fwdSwitch) + fireEvent.click(screen.getByText('tools.mcp.modal.confirm')) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + identity_mode: 'idp_token', + }), + ) + }) + }) + + it('clamps to identity_mode="off" when SSO is unavailable, even if existing data had it on', async () => { + mockSystemFeatures.sso_enforced_for_signin = false + const onConfirm = vi.fn() + const mockData = { + id: 'existing-1', + name: 'srv', + server_url: 'https://example.com/mcp', + server_identifier: 'srv-id', + icon: { content: '🔗', background: '#6366F1' }, + identity_mode: 'idp_token', + } as unknown as ToolWithProvider + + render( + , + { wrapper: createWrapper() }, + ) + fireEvent.click(screen.getByText('tools.mcp.modal.save')) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + identity_mode: 'off', + }), + ) + }) + }) + }) }) diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index fe3dde2cb6e420..ce5405946e40b1 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -497,4 +497,51 @@ describe('useMCPModalForm', () => { expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png') }) }) + + // M3 — Forward-user-identity toggle (PR #36840). The hook stores a bool, + // hydrates it from data.identity_mode (true iff non-"off"), and exposes a + // setter. + describe('Forward-user-identity toggle', () => { + it('defaults to false in create mode', () => { + const { result } = renderHook(() => useMCPModalForm()) + expect(result.current.state.forwardUserIdentity).toBe(false) + }) + + it('hydrates as true when data.identity_mode is "idp_token"', () => { + const mockData = { + id: 'existing-1', + icon: { content: '🔗', background: '#6366F1' }, + identity_mode: 'idp_token', + } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + expect(result.current.state.forwardUserIdentity).toBe(true) + }) + + it('hydrates as false when data.identity_mode is missing or "off"', () => { + const mockData = { + id: 'existing-2', + icon: { content: '🔗', background: '#6366F1' }, + // identity_mode intentionally omitted + } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + expect(result.current.state.forwardUserIdentity).toBe(false) + }) + + it('updates state via setForwardUserIdentity', () => { + const { result } = renderHook(() => useMCPModalForm()) + expect(result.current.state.forwardUserIdentity).toBe(false) + + act(() => { + result.current.actions.setForwardUserIdentity(true) + }) + expect(result.current.state.forwardUserIdentity).toBe(true) + + act(() => { + result.current.actions.setForwardUserIdentity(false) + }) + expect(result.current.state.forwardUserIdentity).toBe(false) + }) + }) }) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts index a1e156fef46731..ca275e8df5d039 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts @@ -54,6 +54,7 @@ type MCPModalFormState = { isDynamicRegistration: boolean clientID: string credentials: string + forwardUserIdentity: boolean } type MCPModalFormActions = { setUrl: (url: string) => void @@ -68,6 +69,7 @@ type MCPModalFormActions = { setIsDynamicRegistration: (value: boolean) => void setClientID: (id: string) => void setCredentials: (credentials: string) => void + setForwardUserIdentity: (value: boolean) => void handleUrlBlur: (url: string) => Promise resetIcon: () => void } @@ -100,6 +102,11 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true)) const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '') const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '') + // M3 — user-identity forwarding. The UI toggle is true iff the persisted + // identity_mode is anything other than "off" — currently just "idp_token". + const [forwardUserIdentity, setForwardUserIdentity] = useState( + () => (data?.identity_mode ?? 'off') !== 'off', + ) const handleUrlBlur = useCallback(async (urlValue: string) => { if (data) return @@ -163,6 +170,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { isDynamicRegistration, clientID, credentials, + forwardUserIdentity, } satisfies MCPModalFormState, // Actions actions: { @@ -178,6 +186,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { setIsDynamicRegistration, setClientID, setCredentials, + setForwardUserIdentity, handleUrlBlur, resetIcon, } satisfies MCPModalFormActions, diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index b5ea71e8e06f70..101f8269bd2a20 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -5,8 +5,10 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine, RiEditLine } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useHover } from 'ahooks' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -15,12 +17,20 @@ import { Mcp } from '@/app/components/base/icons/src/vender/other' import Input from '@/app/components/base/input' import TabSlider from '@/app/components/base/tab-slider' import { MCPAuthMethod } from '@/app/components/tools/types' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form' import AuthenticationSection from './sections/authentication-section' import ConfigurationsSection from './sections/configurations-section' import HeadersSection from './sections/headers-section' +// SSO protocols whose token-endpoint flow supports refresh-token issuance and +// therefore can back MCP per-user identity forwarding. SAML cannot — it has +// no refresh model and no token endpoint, so the enterprise side returns the +// disabled stub for it. +const MCP_FORWARDING_CAPABLE_PROTOCOLS = ['oidc', 'oauth2'] as const +type MCPForwardingCapableProtocol = typeof MCP_FORWARDING_CAPABLE_PROTOCOLS[number] + type MCPModalConfirmPayload = { name: string server_url: string @@ -39,6 +49,7 @@ type MCPModalConfirmPayload = { timeout: number sse_read_timeout: number } + identity_mode?: 'off' | 'idp_token' } type DuplicateAppModalProps = { @@ -70,6 +81,13 @@ const MCPModalContent: FC = ({ actions, } = useMCPModalForm(data) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + // SAML has no refresh_token model, so the enterprise side can't mint + // per-call MCP tokens. Only OIDC and OAuth2 can — gate the toggle on + // both "SSO enforced" AND "protocol is refresh-capable". + const ssoProtocol = systemFeatures.sso_enforced_for_signin_protocol as MCPForwardingCapableProtocol + const isForwardIdentitySupported = systemFeatures.sso_enforced_for_signin && MCP_FORWARDING_CAPABLE_PROTOCOLS.includes(ssoProtocol) + const isHovering = useHover(appIconRef) const authMethods = [ @@ -110,6 +128,9 @@ const MCPModalContent: FC = ({ timeout: state.timeout || 30, sse_read_timeout: state.sseReadTimeout || 300, }, + // Edit-mode data may carry idp_token; clamp to off when SSO is no + // longer available so a stale row can't keep forwarding configured. + identity_mode: state.forwardUserIdentity && isForwardIdentitySupported ? 'idp_token' : 'off', }) if (isCreate) onHide() @@ -207,6 +228,28 @@ const MCPModalContent: FC = ({ )} + {isForwardIdentitySupported && ( +
+
+ + + {t('mcp.modal.forwardUserIdentity', { ns: 'tools' })} + +
+
+ {t('mcp.modal.forwardUserIdentityTip', { ns: 'tools' })} +
+
+ )} + {/* Auth Method Tabs */} { timeout?: number sse_read_timeout?: number headers?: Record + identity_mode?: 'off' | 'idp_token' }) => { return post('workspaces/current/tool-provider/mcp', { body: { @@ -133,6 +134,7 @@ export const useUpdateMCP = ({ timeout?: number sse_read_timeout?: number headers?: Record + identity_mode?: 'off' | 'idp_token' }) => { return put('workspaces/current/tool-provider/mcp', { body: {