mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
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:
parent
23cd129802
commit
d16a012575
@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "请求头值",
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user