Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions web/app/components/tools/mcp/__tests__/modal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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',
}),
)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
9 changes: 9 additions & 0 deletions web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type MCPModalFormState = {
isDynamicRegistration: boolean
clientID: string
credentials: string
forwardUserIdentity: boolean
}
type MCPModalFormActions = {
setUrl: (url: string) => void
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -163,6 +170,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
isDynamicRegistration,
clientID,
credentials,
forwardUserIdentity,
} satisfies MCPModalFormState,
// Actions
actions: {
Expand All @@ -178,6 +186,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
setIsDynamicRegistration,
setClientID,
setCredentials,
setForwardUserIdentity,
handleUrlBlur,
resetIcon,
} satisfies MCPModalFormActions,
Expand Down
43 changes: 43 additions & 0 deletions web/app/components/tools/mcp/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -39,6 +49,7 @@ type MCPModalConfirmPayload = {
timeout: number
sse_read_timeout: number
}
identity_mode?: 'off' | 'idp_token'
}

type DuplicateAppModalProps = {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions web/app/components/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions web/i18n/en-US/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions web/i18n/zh-Hans/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "请求头值",
Expand Down
2 changes: 2 additions & 0 deletions web/service/use-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Loading