feat(web): add Forward-user-identity toggle to MCP provider modal (#36840)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charles Yao 2026-06-05 10:38:36 +02:00 committed by GitHub
parent 23cd129802
commit d16a012575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 256 additions and 0 deletions

View File

@ -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(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.modal.forwardUserIdentity')).not.toBeInTheDocument()
})
it('renders the toggle and helper tip when SSO is configured', () => {
enableRefreshCapableSSO()
render(<MCPModal {...defaultProps} />, { 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(<MCPModal {...defaultProps} />, { 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(<MCPModal {...defaultProps} />, { 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(
<MCPModal {...defaultProps} onConfirm={onConfirm} />,
{ 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(
<MCPModal {...defaultProps} onConfirm={onConfirm} />,
{ 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(
<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByText('tools.mcp.modal.save'))
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
identity_mode: 'off',
}),
)
})
})
})
})

View File

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

View File

@ -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<void>
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,

View File

@ -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<MCPModalContentProps> = ({
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<MCPModalContentProps> = ({
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<MCPModalContentProps> = ({
)}
</div>
{isForwardIdentitySupported && (
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
checked={state.forwardUserIdentity}
onCheckedChange={actions.setForwardUserIdentity}
aria-labelledby="mcp-forward-user-identity-label"
/>
<span
id="mcp-forward-user-identity-label"
className="system-sm-medium text-text-secondary"
>
{t('mcp.modal.forwardUserIdentity', { ns: 'tools' })}
</span>
</div>
<div className="body-xs-regular text-text-tertiary">
{t('mcp.modal.forwardUserIdentityTip', { ns: 'tools' })}
</div>
</div>
)}
{/* Auth Method Tabs */}
<TabSlider
className="w-full"

View File

@ -78,6 +78,11 @@ export type Collection = {
timeout?: number
sse_read_timeout?: number
}
// M3 — user-identity forwarding (MCP). Single selector now drives both
// "is forwarding on?" and "which mechanism to use?". Pre-collapse builds
// also sent a redundant `forward_user_identity` boolean; the api dropped
// it, so the field is gone here too.
identity_mode?: 'off' | 'idp_token'
// Workflow
workflow_app_id?: string
}

View File

@ -120,6 +120,8 @@
"mcp.modal.configurations": "Configurations",
"mcp.modal.confirm": "Add & Authorize",
"mcp.modal.editTitle": "Edit MCP Server (HTTP)",
"mcp.modal.forwardUserIdentity": "Forward user identity",
"mcp.modal.forwardUserIdentityTip": "Send the calling user's verified SSO identity to this MCP server as an Authorization Bearer token. Requires Dify Enterprise SSO.",
"mcp.modal.headerKey": "Header Name",
"mcp.modal.headerKeyPlaceholder": "e.g., Authorization",
"mcp.modal.headerValue": "Header Value",

View File

@ -120,6 +120,8 @@
"mcp.modal.configurations": "配置",
"mcp.modal.confirm": "添加并授权",
"mcp.modal.editTitle": "修改 MCP 服务 (HTTP)",
"mcp.modal.forwardUserIdentity": "转发用户身份",
"mcp.modal.forwardUserIdentityTip": "将调用用户的已验证 SSO 身份作为 Authorization Bearer token 转发到该 MCP 服务器。需要 Dify Enterprise SSO。",
"mcp.modal.headerKey": "请求头名称",
"mcp.modal.headerKeyPlaceholder": "例如Authorization",
"mcp.modal.headerValue": "请求头值",

View File

@ -106,6 +106,7 @@ export const useCreateMCP = () => {
timeout?: number
sse_read_timeout?: number
headers?: Record<string, string>
identity_mode?: 'off' | 'idp_token'
}) => {
return post<ToolWithProvider>('workspaces/current/tool-provider/mcp', {
body: {
@ -133,6 +134,7 @@ export const useUpdateMCP = ({
timeout?: number
sse_read_timeout?: number
headers?: Record<string, string>
identity_mode?: 'off' | 'idp_token'
}) => {
return put('workspaces/current/tool-provider/mcp', {
body: {