diff --git a/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx b/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx index 3de0571c12..e91c203e6e 100644 --- a/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx +++ b/frontend/src/app/workspaces/[workspaceId]/integrations/page.tsx @@ -1,44 +1,26 @@ "use client" -import { useMutation, useQueryClient } from "@tanstack/react-query" import { ChevronRight, Loader2, RotateCcw, SquareAsterisk } from "lucide-react" import { useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { IntegrationStatus, OAuthGrantType } from "@/client" -import { - integrationsConnectProvider, - integrationsDisconnectIntegration, - integrationsTestConnection, -} from "@/client" import { useScopeCheck } from "@/components/auth/scope-guard" +import { ConfirmDestructiveDialog } from "@/components/confirm-destructive-dialog" import { ProviderIcon } from "@/components/icons" import { type ConnectionFilter, IntegrationsHeader, type IntegrationTypeFilter, } from "@/components/integrations/integrations-header" -import { MCPIntegrationDialog } from "@/components/integrations/mcp-integration-dialog" import { OAuthIntegrationDetailsDialog } from "@/components/integrations/oauth-integration-details-dialog" import { OAuthIntegrationDialog } from "@/components/integrations/oauth-integration-dialog" import { CenteredSpinner } from "@/components/loading/spinner" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" -import { Input } from "@/components/ui/input" import { Item, ItemActions, @@ -46,7 +28,6 @@ import { ItemMedia, ItemTitle, } from "@/components/ui/item" -import { Label } from "@/components/ui/label" import { ScrollArea } from "@/components/ui/scroll-area" import { Tooltip, @@ -54,62 +35,58 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { toast } from "@/components/ui/use-toast" -import type { TracecatApiError } from "@/lib/errors" import { - useDeleteMcpIntegration, - useIntegrations, - useListMcpIntegrations, -} from "@/lib/hooks" + useConnectProvider, + useDisconnectProvider, + useTestProvider, +} from "@/hooks/use-integration-actions" +import { useIntegrations } from "@/lib/hooks" +import { isMcpProvider } from "@/lib/integrations" import { cn } from "@/lib/utils" import { useWorkspaceId } from "@/providers/workspace-id" -type IntegrationItem = - | { - type: "oauth" - id: string - name: string - description: string - enabled: boolean - integration_status: IntegrationStatus - grant_type: OAuthGrantType - requires_config: boolean - } - | { - type: "mcp" - id: string - name: string - description: string | null - slug: string - server_uri: string | null - auth_type: string - oauth_integration_id?: string | null - } +type IntegrationItem = { + type: "oauth" + id: string + name: string + description: string + enabled: boolean + integration_status: IntegrationStatus + grant_type: OAuthGrantType + requires_config: boolean +} const displayStatus = (status: IntegrationStatus) => status === "configured" ? "not_configured" : status -type IntegrationSectionType = "oauth" | "mcp" | "custom_oauth" | "custom_mcp" +type IntegrationSectionType = "oauth" | "custom_oauth" const integrationTypeLabels = { oauth: "OAuth", - mcp: "MCP", custom_oauth: "Custom OAuth", - custom_mcp: "Custom MCP", } as const const integrationSectionOrder: IntegrationSectionType[] = [ "oauth", "custom_oauth", - "mcp", - "custom_mcp", ] const integrationSectionTitles: Record = { oauth: "OAuth", custom_oauth: "Custom OAuth", - mcp: "MCP", - custom_mcp: "Custom MCP", +} + +function getIntegrationStatus(item: IntegrationItem): IntegrationStatus { + return displayStatus(item.integration_status) +} + +function getIntegrationDisplayType( + item: IntegrationItem +): IntegrationSectionType { + if (item.id.startsWith("custom_")) { + return "custom_oauth" + } + return item.type } export default function IntegrationsPage() { @@ -131,204 +108,39 @@ export default function IntegrationsPage() { providerId: string grantType: OAuthGrantType } | null>(null) - const [activeMcpIntegrationId, setActiveMcpIntegrationId] = useState< - string | null - >(null) - const [pendingMcpDeleteId, setPendingMcpDeleteId] = useState( - null - ) - const [disconnectConfirmTextByKey, setDisconnectConfirmTextByKey] = useState< - Record - >({}) + const [disconnectTarget, setDisconnectTarget] = useState<{ + providerId: string + grantType: OAuthGrantType + name: string + } | null>(null) const [expandedSections, setExpandedSections] = useState< Record >({ oauth: false, custom_oauth: false, - mcp: false, - custom_mcp: false, }) const lastHandledConnectRef = useRef(null) - const { integrations, providers, providersIsLoading, providersError } = + const { providers, providersIsLoading, providersError } = useIntegrations(workspaceId) - const { mcpIntegrations, mcpIntegrationsIsLoading, mcpIntegrationsError } = - useListMcpIntegrations(workspaceId) - const { deleteMcpIntegration, deleteMcpIntegrationIsPending } = - useDeleteMcpIntegration(workspaceId) - - const queryClient = useQueryClient() - const handleTypeFilterToggle = useCallback( - (filter: IntegrationTypeFilter) => { - setTypeFilters((prev) => - prev.includes(filter) - ? prev.filter((value) => value !== filter) - : [...prev, filter] - ) - }, - [] - ) - - const invalidateIntegrationQueries = useCallback( - (providerId: string, grantType: OAuthGrantType) => { - queryClient.invalidateQueries({ - queryKey: ["integration", providerId, workspaceId, grantType], - }) - queryClient.invalidateQueries({ queryKey: ["providers", workspaceId] }) - queryClient.invalidateQueries({ - queryKey: ["integrations", workspaceId], - }) - }, - [queryClient, workspaceId] - ) - - const connectProviderMutation = useMutation({ - mutationFn: async ({ providerId }: { providerId: string }) => - await integrationsConnectProvider({ providerId, workspaceId }), - onSuccess: (result) => { - window.location.href = result.auth_url - }, - onError: (error: TracecatApiError) => { - console.error("Failed to connect provider:", error) - toast({ - title: "Failed to connect", - description: `Could not connect to provider: ${error.body?.detail || error.message}`, - }) - }, - }) - - const disconnectProviderMutation = useMutation({ - mutationFn: async ({ - providerId, - grantType, - }: { - providerId: string - grantType: OAuthGrantType - }) => - await integrationsDisconnectIntegration({ - providerId, - workspaceId, - grantType, - }), - onSuccess: (_, variables) => { - invalidateIntegrationQueries(variables.providerId, variables.grantType) - toast({ - title: "Disconnected", - description: "Successfully disconnected from provider", - }) - }, - onError: (error: TracecatApiError) => { - console.error("Failed to disconnect provider:", error) - toast({ - title: "Failed to disconnect", - description: `Could not disconnect from provider: ${error.body?.detail || error.message}`, - variant: "destructive", - }) - }, - }) - - const testConnectionMutation = useMutation({ - mutationFn: async ({ - providerId, - }: { - providerId: string - grantType: OAuthGrantType - }) => await integrationsTestConnection({ providerId, workspaceId }), - onSuccess: (result, variables) => { - if (result.success) { - invalidateIntegrationQueries(variables.providerId, variables.grantType) - toast({ - title: "Connection successful", - description: result.message, - }) - } else { - toast({ - title: "Connection failed", - description: result.error || result.message, - variant: "destructive", - }) - } - }, - onError: (error: TracecatApiError) => { - console.error("Failed to test connection:", error) - toast({ - title: "Test failed", - description: `Could not test connection: ${error.body?.detail || error.message}`, - variant: "destructive", - }) - }, - }) - - const integrationById = useMemo( - () => - new Map( - (integrations ?? []).map((integration) => [integration.id, integration]) - ), - [integrations] - ) - - const getIntegrationStatus = useCallback( - (item: IntegrationItem): IntegrationStatus => { - if (item.type === "mcp") { - return getMcpDisplayStatus(item, integrationById) - } - return displayStatus(item.integration_status) - }, - [integrationById] - ) - const isCustomMcpIntegration = useCallback( - (item: Extract) => { - if (item.auth_type !== "OAUTH2") { - return true - } - if (!item.oauth_integration_id) { - return true - } - const integration = integrationById.get(item.oauth_integration_id) - return !integration?.provider_id.endsWith("_mcp") - }, - [integrationById] - ) + function handleTypeFilterToggle(filter: IntegrationTypeFilter) { + setTypeFilters((prev) => + prev.includes(filter) + ? prev.filter((value) => value !== filter) + : [...prev, filter] + ) + } - const getIntegrationDisplayType = useCallback( - (item: IntegrationItem): IntegrationSectionType => { - if (item.type === "oauth" && item.id.startsWith("custom_")) { - return "custom_oauth" - } - if (item.type === "oauth" && item.id.endsWith("_mcp")) { - return "mcp" - } - if (item.type === "mcp" && isCustomMcpIntegration(item)) { - return "custom_mcp" - } - return item.type - }, - [isCustomMcpIntegration] - ) + const connectProviderMutation = useConnectProvider(workspaceId) + const disconnectProviderMutation = useDisconnectProvider(workspaceId) + const testConnectionMutation = useTestProvider(workspaceId) const allIntegrations = useMemo(() => { - // Track which MCP provider IDs already have MCP integration records - // so we don't show duplicates for connected MCP OAuth providers - const mcpOAuthProviderIds = new Set( - (mcpIntegrations ?? []) - .filter((mcp) => mcp.auth_type === "OAUTH2" && mcp.oauth_integration_id) - .flatMap((mcp) => { - const integration = integrationById.get(mcp.oauth_integration_id!) - return integration?.provider_id ? [integration.provider_id] : [] - }) - ) - - const oauthItems: IntegrationItem[] = + return ( providers - ?.filter((provider) => { - // Exclude MCP OAuth providers that already have MCP integration records - if (provider.id.endsWith("_mcp")) { - return !mcpOAuthProviderIds.has(provider.id) - } - return true - }) - .map((provider) => ({ + ?.filter((provider) => !isMcpProvider(provider.id)) + .map((provider) => ({ type: "oauth" as const, id: provider.id, name: provider.name, @@ -338,43 +150,20 @@ export default function IntegrationsPage() { grant_type: provider.grant_type, requires_config: provider.requires_config, })) ?? [] - - const mcpItems: IntegrationItem[] = - mcpIntegrations?.map((mcp) => ({ - type: "mcp" as const, - id: mcp.id, - name: mcp.name, - description: mcp.description, - slug: mcp.slug, - server_uri: mcp.server_uri, - auth_type: mcp.auth_type, - oauth_integration_id: mcp.oauth_integration_id, - })) ?? [] - - return [...oauthItems, ...mcpItems] - }, [providers, mcpIntegrations, integrationById]) + ) + }, [providers]) const filteredIntegrations = useMemo(() => { + const q = searchQuery.toLowerCase() const filtered = allIntegrations.filter((item) => { const matchesSearch = - item.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (item.description ?? "") - .toLowerCase() - .includes(searchQuery.toLowerCase()) + item.name.toLowerCase().includes(q) || + (item.description ?? "").toLowerCase().includes(q) const matchesType = typeFilters.length === 0 || typeFilters.some((filter) => { if (filter === "custom_oauth") { - return item.type === "oauth" && item.id.startsWith("custom_") - } - if (filter === "custom_mcp") { - return item.type === "mcp" && isCustomMcpIntegration(item) - } - if (filter === "mcp") { - return ( - item.type === "mcp" || - (item.type === "oauth" && item.id.endsWith("_mcp")) - ) + return item.id.startsWith("custom_") } return item.type === filter }) @@ -406,22 +195,13 @@ export default function IntegrationsPage() { return aOrder - bOrder } - if (a.type === "oauth" && b.type === "oauth") { - if (a.enabled !== b.enabled) { - return a.enabled ? -1 : 1 - } + if (a.enabled !== b.enabled) { + return a.enabled ? -1 : 1 } return a.name.localeCompare(b.name) }) - }, [ - allIntegrations, - connectionFilter, - getIntegrationStatus, - isCustomMcpIntegration, - searchQuery, - typeFilters, - ]) + }, [allIntegrations, connectionFilter, searchQuery, typeFilters]) const sectionedIntegrations = useMemo(() => { const groupedIntegrations: Record< @@ -430,8 +210,6 @@ export default function IntegrationsPage() { > = { oauth: [], custom_oauth: [], - mcp: [], - custom_mcp: [], } for (const item of filteredIntegrations) { @@ -448,7 +226,7 @@ export default function IntegrationsPage() { (section) => section.items.length > 0 || section.sectionType === "custom_oauth" ) - }, [filteredIntegrations, getIntegrationDisplayType]) + }, [filteredIntegrations]) const connectParam = searchParams?.get("connect") const connectGrantType = searchParams?.get( @@ -554,13 +332,6 @@ export default function IntegrationsPage() { providers, ]) - const handleOAuthDisconnect = useCallback( - async (providerId: string, grantType: OAuthGrantType) => { - await disconnectProviderMutation.mutateAsync({ providerId, grantType }) - }, - [disconnectProviderMutation] - ) - const handleReconnect = useCallback( (providerId: string, grantType: OAuthGrantType) => { handleDirectConnect(providerId, grantType) @@ -568,41 +339,10 @@ export default function IntegrationsPage() { [handleDirectConnect] ) - const handleMcpDisconnect = useCallback( - async (mcpIntegrationId: string) => { - setPendingMcpDeleteId(mcpIntegrationId) - try { - await deleteMcpIntegration(mcpIntegrationId) - } finally { - setPendingMcpDeleteId(null) - } - }, - [deleteMcpIntegration] - ) - - const getDisconnectKey = useCallback((item: IntegrationItem) => { - if (item.type === "oauth") { - return `oauth:${item.id}:${item.grant_type}` - } - return `mcp:${item.id}` - }, []) - - const resetDisconnectConfirmText = useCallback((key: string) => { - setDisconnectConfirmTextByKey((prev) => { - if (!prev[key]) { - return prev - } - const next = { ...prev } - delete next[key] - return next - }) - }, []) - if ( canReadIntegrations === undefined || canUpdateIntegrations === undefined || - providersIsLoading || - mcpIntegrationsIsLoading + providersIsLoading ) { return } @@ -610,12 +350,8 @@ export default function IntegrationsPage() { if (!canReadIntegrations) { return null } - if (providersError || mcpIntegrationsError) { - return ( -
- Error: {providersError?.message || mcpIntegrationsError?.message} -
- ) + if (providersError) { + return
Error: {providersError.message}
} return ( @@ -669,61 +405,37 @@ export default function IntegrationsPage() {
{section.items.map((item) => { - const isOAuth = item.type === "oauth" - const isMcp = item.type === "mcp" const status = getIntegrationStatus(item) - const isConnected = isMcp - ? status === "connected" - : item.integration_status === "connected" + const isConnected = + item.integration_status === "connected" const isConfigured = status === "connected" const isClickable = - (isOAuth && isConnected) || + isConnected || (canMutateIntegrations && - ((isOAuth && - item.requires_config && - item.enabled) || - isMcp)) - const isDisabled = isOAuth ? !item.enabled : false + item.requires_config && + item.enabled) + const isDisabled = !item.enabled const showConnect = canMutateIntegrations && !isConnected const showDisconnect = canMutateIntegrations && isConnected const isConnecting = - isOAuth && - ((connectProviderMutation.isPending && + (connectProviderMutation.isPending && connectProviderMutation.variables?.providerId === item.id) || - (testConnectionMutation.isPending && - testConnectionMutation.variables?.providerId === - item.id)) + (testConnectionMutation.isPending && + testConnectionMutation.variables?.providerId === + item.id) const isDisconnecting = - isOAuth && disconnectProviderMutation.isPending && disconnectProviderMutation.variables?.providerId === item.id - const isDeletingMcp = - item.type === "mcp" && - pendingMcpDeleteId === item.id && - deleteMcpIntegrationIsPending - const disconnectKey = getDisconnectKey(item) - const disconnectConfirmText = - disconnectConfirmTextByKey[disconnectKey] ?? "" const displayType = getIntegrationDisplayType(item) const typeLabel = integrationTypeLabels[displayType] - const mcpProviderIconId = isMcp - ? item.slug.endsWith("_mcp") - ? item.slug - : `${item.slug}_mcp` - : null - return ( { - if (isOAuth) { - if (isConnected) { - setDetailsProvider({ - providerId: item.id, - grantType: item.grant_type, - }) - return - } - if (item.requires_config && item.enabled) { - if (!canMutateIntegrations) { - return - } - handleOpenOAuthModal(item.id, item.grant_type) - } + if (isConnected) { + setDetailsProvider({ + providerId: item.id, + grantType: item.grant_type, + }) return } - if (isMcp) { + if (item.requires_config && item.enabled) { if (!canMutateIntegrations) { return } - setActiveMcpIntegrationId(item.id) + handleOpenOAuthModal(item.id, item.grant_type) } }} > - {isOAuth ? ( - - ) : mcpProviderIconId ? ( - - ) : ( -
- MCP -
- )} +
@@ -807,35 +499,33 @@ export default function IntegrationsPage() { ) : null} - {isOAuth && - isConnected && - canMutateIntegrations && ( - - - - - - -

Reconnect

-
-
-
- )} + {isConnected && canMutateIntegrations && ( + + + + + + +

Reconnect

+
+
+
+ )} {showConnect && ( <> - - event.stopPropagation()} - > - - - Disconnect integration - - -

- {`Are you sure you want to disconnect from ${item.name}?`} -

-
- - - setDisconnectConfirmTextByKey( - (prev) => ({ - ...prev, - [disconnectKey]: - event.target.value, - }) - ) - } - placeholder="Enter integration name" - disabled={ - isDisconnecting || isDeletingMcp - } - /> -
-
-
- - - Cancel - - { - if ( - disconnectConfirmText.trim() !== - item.name - ) { - return - } - if (isOAuth) { - await handleOAuthDisconnect( - item.id, - item.grant_type - ) - } else { - await handleMcpDisconnect(item.id) - } - resetDisconnectConfirmText( - disconnectKey - ) - }} - disabled={ - isDisconnecting || - isDeletingMcp || - disconnectConfirmText.trim() !== - item.name - } - > - {(isDisconnecting || isDeletingMcp) && ( - - )} - Disconnect - - -
- + Disconnect + )}
@@ -1010,35 +610,35 @@ export default function IntegrationsPage() { }} providerId={detailsProvider.providerId} grantType={detailsProvider.grantType} + canUpdate={canMutateIntegrations} /> )} - {activeMcpIntegrationId ? ( - { - if (!nextOpen) { - setActiveMcpIntegrationId(null) - } - }} - mcpIntegrationId={activeMcpIntegrationId} - hideTrigger - /> - ) : null} + { + if (!next) setDisconnectTarget(null) + }} + confirmPhrase={disconnectTarget?.name ?? ""} + title="Disconnect integration" + description={ + disconnectTarget ? ( + <> + Are you sure you want to disconnect from{" "} + {disconnectTarget.name}? + + ) : null + } + confirmLabel="Disconnect" + isPending={disconnectProviderMutation.isPending} + onConfirm={async () => { + if (!disconnectTarget) return + await disconnectProviderMutation.mutateAsync({ + providerId: disconnectTarget.providerId, + grantType: disconnectTarget.grantType, + }) + setDisconnectTarget(null) + }} + />
) } - -function getMcpDisplayStatus( - item: Extract, - integrationById: Map -): IntegrationStatus { - if (item.auth_type === "OAUTH2") { - if (!item.oauth_integration_id) { - return "not_configured" - } - const integration = integrationById.get(item.oauth_integration_id) - return integration?.status === "connected" ? "connected" : "not_configured" - } - return "connected" -} diff --git a/frontend/src/app/workspaces/[workspaceId]/mcp-servers/page.tsx b/frontend/src/app/workspaces/[workspaceId]/mcp-servers/page.tsx new file mode 100644 index 0000000000..262c54bb5b --- /dev/null +++ b/frontend/src/app/workspaces/[workspaceId]/mcp-servers/page.tsx @@ -0,0 +1,453 @@ +"use client" + +import { useQueryClient } from "@tanstack/react-query" +import { Globe, Loader2, Plus, Sparkles, Terminal } from "lucide-react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useEffect, useMemo, useState } from "react" +import type { + MCPIntegrationRead, + OAuthGrantType, + ProviderReadMinimal, +} from "@/client" +import { useScopeCheck } from "@/components/auth/scope-guard" +import { CatalogHeader } from "@/components/catalog/catalog-header" +import { ProviderIcon } from "@/components/icons" +import { MCPIntegrationDialog } from "@/components/integrations/mcp-integration-dialog" +import { OAuthIntegrationDetailsDialog } from "@/components/integrations/oauth-integration-details-dialog" +import { OAuthIntegrationDialog } from "@/components/integrations/oauth-integration-dialog" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { useConnectProvider } from "@/hooks/use-integration-actions" +import { useIntegrations, useListMcpIntegrations } from "@/lib/hooks" +import { integrationKeys, isMcpProvider } from "@/lib/integrations" +import { formatRelative } from "@/lib/time" +import { useWorkspaceId } from "@/providers/workspace-id" + +const CREATE_MCP_SERVER_PARAM = "createMcpServer" + +type PresetFilter = "all" | "connected" | "workspace" + +type McpItem = + | { + kind: "platform" + sortKey: string + provider: ProviderReadMinimal + } + | { + kind: "workspace" + sortKey: string + mcp: MCPIntegrationRead + } + +const PRESET_FILTERS: Array<{ value: PresetFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "connected", label: "Connected" }, + { value: "workspace", label: "Workspace" }, +] + +export default function McpServersPage() { + const workspaceId = useWorkspaceId() + const canRead = useScopeCheck("integration:read") + const canCreate = useScopeCheck("integration:create") + const canUpdate = useScopeCheck("integration:update") + const canCreateMcp = canCreate === true + const canUpdateIntegrations = canUpdate === true + + const { mcpIntegrations, mcpIntegrationsIsLoading, mcpIntegrationsError } = + useListMcpIntegrations(workspaceId, "workspace") + const { + integrations, + providers, + integrationsIsLoading, + providersIsLoading, + providersError, + integrationsError, + } = useIntegrations(workspaceId) + + const [searchQuery, setSearchQuery] = useState("") + const [presetFilter, setPresetFilter] = useState("all") + const [createOpen, setCreateOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [activeProvider, setActiveProvider] = useState<{ + providerId: string + grantType: OAuthGrantType + } | null>(null) + const [configProvider, setConfigProvider] = useState<{ + providerId: string + grantType: OAuthGrantType + } | null>(null) + + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + // Subscribe to the primitive instead of the whole searchParams object: this + // effect should only re-run when our specific param flips, not on every + // unrelated query-string change. + const createSignal = searchParams?.get(CREATE_MCP_SERVER_PARAM) ?? null + + useEffect(() => { + if (!createSignal || !pathname) { + return + } + if (canCreate === undefined) { + return + } + if (canCreate === true) { + setCreateOpen(true) + } + const params = new URLSearchParams(searchParams?.toString() ?? "") + params.delete(CREATE_MCP_SERVER_PARAM) + const next = params.toString() + router.replace(next ? `${pathname}?${next}` : pathname, { scroll: false }) + // searchParams is read once to compose the cleanup URL; the dependency + // we actually watch is `createSignal`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canCreate, createSignal, pathname, router]) + + const queryClient = useQueryClient() + const connectProviderMutation = useConnectProvider(workspaceId) + + const items = useMemo(() => { + const platformItems: McpItem[] = (providers ?? []) + .filter((p) => isMcpProvider(p.id)) + .map((provider) => ({ + kind: "platform" as const, + sortKey: provider.name.toLowerCase(), + provider, + })) + + const workspaceItems: McpItem[] = (mcpIntegrations ?? []).map((mcp) => ({ + kind: "workspace" as const, + sortKey: mcp.name.toLowerCase(), + mcp, + })) + + return [...platformItems, ...workspaceItems].sort((a, b) => + a.sortKey.localeCompare(b.sortKey) + ) + }, [mcpIntegrations, providers]) + + const connectedOAuthIntegrationIds = useMemo(() => { + return new Set( + (integrations ?? []) + .filter((integration) => integration.status === "connected") + .map((integration) => integration.id) + ) + }, [integrations]) + + // Two-pass filter: preset slice only changes when items or the preset + // selection changes; the search pass only runs when the query changes. + const presetFilteredItems = useMemo(() => { + return items.filter((item) => { + if (presetFilter === "workspace") { + return item.kind === "workspace" + } + if (presetFilter === "connected") { + if (item.kind === "platform") { + return item.provider.integration_status === "connected" + } + if (item.mcp.auth_type !== "OAUTH2") { + return true + } + return ( + item.mcp.oauth_integration_id !== null && + connectedOAuthIntegrationIds.has(item.mcp.oauth_integration_id) + ) + } + return true + }) + }, [connectedOAuthIntegrationIds, items, presetFilter]) + + const filteredItems = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return presetFilteredItems + return presetFilteredItems.filter((item) => { + if (item.kind === "platform") { + const { name, description, id } = item.provider + return ( + name.toLowerCase().includes(q) || + (description ?? "").toLowerCase().includes(q) || + id.toLowerCase().includes(q) + ) + } + const { name, description, slug } = item.mcp + return ( + name.toLowerCase().includes(q) || + (description ?? "").toLowerCase().includes(q) || + slug.toLowerCase().includes(q) + ) + }) + }, [presetFilteredItems, searchQuery]) + + const totalCount = filteredItems.length + const isLoading = + mcpIntegrationsIsLoading || providersIsLoading || integrationsIsLoading + const loadError = mcpIntegrationsError ?? providersError ?? integrationsError + + if (canRead === undefined) { + return ( +
+ +
+ ) + } + + if (canRead === false) { + return ( +
+ + Access denied + + You do not have permission to view MCP servers. + + +
+ ) + } + + return ( +
+ setPresetFilter(value)} + displayCount={totalCount} + countLabel={`server${totalCount === 1 ? "" : "s"}`} + /> + +
+
+ {loadError ? ( + + Failed to load MCP servers + + {String(loadError.body?.detail ?? loadError.message)} + + + ) : null} + + {isLoading ? ( +
+ +
+ ) : totalCount === 0 ? ( + + +
+

No MCP servers yet

+

+ Connect Tracecat agents to an MCP server to extend their + toolset. +

+
+ {canCreateMcp ? ( + + ) : null} +
+ ) : ( +
+ {filteredItems.map((item) => + item.kind === "platform" ? ( + { + if (item.provider.requires_config) { + setConfigProvider({ + providerId: item.provider.id, + grantType: item.provider.grant_type, + }) + return + } + connectProviderMutation.mutate({ + providerId: item.provider.id, + }) + }} + onManage={() => + setActiveProvider({ + providerId: item.provider.id, + grantType: item.provider.grant_type, + }) + } + /> + ) : ( + setEditingId(item.mcp.id)} + /> + ) + )} +
+ )} +
+
+ + {activeProvider ? ( + { + if (!next) { + setActiveProvider(null) + queryClient.invalidateQueries({ + queryKey: integrationKeys.providers(workspaceId), + }) + } + }} + /> + ) : null} + + {configProvider ? ( + { + if (!next) { + setConfigProvider(null) + queryClient.invalidateQueries({ + queryKey: integrationKeys.providers(workspaceId), + }) + } + }} + /> + ) : null} + + {createOpen ? ( + + ) : null} + + {editingId !== null ? ( + { + if (!next) setEditingId(null) + }} + mcpIntegrationId={editingId} + hideTrigger + /> + ) : null} +
+ ) +} + +interface PlatformMcpCardProps { + provider: ProviderReadMinimal + canMutate: boolean + isConnecting: boolean + onConnect: () => void + onManage: () => void +} + +function PlatformMcpCard({ + provider, + canMutate, + isConnecting, + onConnect, + onManage, +}: PlatformMcpCardProps) { + const connected = provider.integration_status === "connected" + return ( + +
+ + {canMutate ? ( + + ) : null} +
+ +
+
+

+ {provider.name} +

+ {connected ? ( + Connected + ) : null} +
+

+ {provider.description ?? "No description"} +

+
+
+ ) +} + +interface McpCardProps { + mcp: MCPIntegrationRead + canMutate: boolean + onEdit: () => void +} + +function McpCard({ mcp, canMutate, onEdit }: McpCardProps) { + const TransportIcon = mcp.server_type === "stdio" ? Terminal : Globe + const lastUpdated = formatRelative(mcp.updated_at) + + return ( + +
+
+ +
+ {canMutate ? ( + + ) : null} +
+ +
+
+

+ {mcp.name} +

+ + {mcp.server_type} + +
+

+ {mcp.description ?? "No description"} +

+ {lastUpdated ? ( +

+ Updated {lastUpdated} +

+ ) : null} +
+
+ ) +} diff --git a/frontend/src/client/services.gen.ts b/frontend/src/client/services.gen.ts index 0f3519b0a1..3e57837079 100644 --- a/frontend/src/client/services.gen.ts +++ b/frontend/src/client/services.gen.ts @@ -11391,9 +11391,10 @@ export const mcpIntegrationsCreateMcpIntegration = ( /** * List Mcp Integrations - * List all MCP integrations for the workspace. + * List MCP integrations for the workspace, optionally filtered by source. * @param data The data for the request. * @param data.workspaceId + * @param data.source Restrict results to platform-managed or workspace-authored MCP integrations. Defaults to all rows. * @returns MCPIntegrationRead Successful Response * @throws ApiError */ @@ -11406,6 +11407,9 @@ export const mcpIntegrationsListMcpIntegrations = ( path: { workspace_id: data.workspaceId, }, + query: { + source: data.source, + }, errors: { 422: "Validation Error", }, diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 342c9ef340..9a3d5cfb01 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -12272,6 +12272,10 @@ export type McpIntegrationsCreateMcpIntegrationData = { export type McpIntegrationsCreateMcpIntegrationResponse = MCPIntegrationRead export type McpIntegrationsListMcpIntegrationsData = { + /** + * Restrict results to platform-managed or workspace-authored MCP integrations. Defaults to all rows. + */ + source?: "platform" | "workspace" | null workspaceId: string } diff --git a/frontend/src/components/catalog/catalog-header.tsx b/frontend/src/components/catalog/catalog-header.tsx index 4d0a96bce0..43c3a15e50 100644 --- a/frontend/src/components/catalog/catalog-header.tsx +++ b/frontend/src/components/catalog/catalog-header.tsx @@ -16,7 +16,7 @@ import { cn } from "@/lib/utils" export interface CatalogHeaderPillOption { value: TValue label: string - icon: LucideIcon + icon?: LucideIcon } export interface CatalogHeaderSelectOption { @@ -115,7 +115,9 @@ export function CatalogHeader({ aria-pressed={isActive} onClick={() => onPillFilterToggle?.(option.value)} > - + {Icon ? ( + + ) : null} {option.label} ) diff --git a/frontend/src/components/confirm-destructive-dialog.tsx b/frontend/src/components/confirm-destructive-dialog.tsx new file mode 100644 index 0000000000..65f275d15c --- /dev/null +++ b/frontend/src/components/confirm-destructive-dialog.tsx @@ -0,0 +1,114 @@ +"use client" + +import { Loader2 } from "lucide-react" +import type { ReactNode } from "react" +import { useState } from "react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface ConfirmDestructiveDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** + * The exact string the user must type to enable confirmation. Typically + * the name of the resource being destroyed. + */ + confirmPhrase: string + title: ReactNode + description: ReactNode + /** + * Label for the confirmation button. Defaults to "Delete". + */ + confirmLabel?: string + /** + * Optional placeholder for the confirmation input. Defaults to the + * `confirmPhrase`. + */ + inputPlaceholder?: string + isPending?: boolean + onConfirm: () => void | Promise +} + +/** + * Destructive-action confirmation that requires the user to type a specific + * phrase before the confirm button is enabled. + * + * Used for disconnecting OAuth integrations and removing workspace MCP + * servers — the type-the-name gate prevents one-click destruction of + * connections that may be in active use. + */ +export function ConfirmDestructiveDialog({ + open, + onOpenChange, + confirmPhrase, + title, + description, + confirmLabel = "Delete", + inputPlaceholder, + isPending = false, + onConfirm, +}: ConfirmDestructiveDialogProps) { + const [confirmText, setConfirmText] = useState("") + + const matches = confirmText.trim() === confirmPhrase + + function handleOpenChange(next: boolean) { + if (!next) setConfirmText("") + onOpenChange(next) + } + + return ( + + + + {title} + {description} + +
+ + setConfirmText(event.target.value)} + placeholder={inputPlaceholder ?? confirmPhrase} + disabled={isPending} + autoComplete="off" + /> +
+ + Cancel + { + event.preventDefault() + if (!matches) return + try { + await onConfirm() + } catch { + // Mutation callbacks handle user-facing errors. + } + }} + > + {isPending ? ( + + ) : null} + {confirmLabel} + + +
+
+ ) +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 90c096d6c0..bf444c3169 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -1685,15 +1685,16 @@ export function SentryIcon({ className, ...rest }: IconProps) { return ( ) diff --git a/frontend/src/components/integrations/integrations-header.tsx b/frontend/src/components/integrations/integrations-header.tsx index a9177b7b0c..e0af2bef8e 100644 --- a/frontend/src/components/integrations/integrations-header.tsx +++ b/frontend/src/components/integrations/integrations-header.tsx @@ -1,24 +1,13 @@ "use client" -import { - Link2, - Lock, - LockKeyhole, - Sparkles, - Unlink2, - WrenchIcon, -} from "lucide-react" +import { Link2, Lock, LockKeyhole, Unlink2 } from "lucide-react" import { CatalogHeader, type CatalogHeaderPillOption, type CatalogHeaderSelectFilter, } from "@/components/catalog/catalog-header" -export type IntegrationTypeFilter = - | "oauth" - | "custom_oauth" - | "mcp" - | "custom_mcp" +export type IntegrationTypeFilter = "oauth" | "custom_oauth" export type ConnectionFilter = "all" | "connected" | "not_connected" interface IntegrationsHeaderProps { @@ -36,8 +25,6 @@ const TYPE_FILTER_OPTIONS: Array< > = [ { value: "oauth", label: "OAuth", icon: Lock }, { value: "custom_oauth", label: "Custom OAuth", icon: LockKeyhole }, - { value: "mcp", label: "MCP", icon: Sparkles }, - { value: "custom_mcp", label: "Custom MCP", icon: WrenchIcon }, ] export function IntegrationsHeader({ diff --git a/frontend/src/components/integrations/mcp-integration-dialog.tsx b/frontend/src/components/integrations/mcp-integration-dialog.tsx index 272de10464..2377cdfa5a 100644 --- a/frontend/src/components/integrations/mcp-integration-dialog.tsx +++ b/frontend/src/components/integrations/mcp-integration-dialog.tsx @@ -4,14 +4,34 @@ import { zodResolver } from "@hookform/resolvers/zod" import { Loader2, Plus, Trash2 } from "lucide-react" import React, { useState } from "react" import { useFieldArray, useForm } from "react-hook-form" -import { z } from "zod" import type { MCPHttpIntegrationCreate, MCPIntegrationUpdate, MCPStdioIntegrationCreate, } from "@/client/types.gen" +import { useScopeCheck } from "@/components/auth/scope-guard" import { CodeEditor } from "@/components/editor/codemirror/code-editor" import { ProviderIcon } from "@/components/icons" +import { + ALLOWED_COMMANDS, + AUTH_TYPES, + isAllowedCommand, + MCP_INTEGRATION_FORM_DEFAULTS, + type MCPIntegrationFormValues, + mcpIntegrationFormSchema, + SERVER_TYPES, +} from "@/components/integrations/mcp-integration-schema" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { Button, type ButtonProps } from "@/components/ui/button" import { Dialog, @@ -42,241 +62,15 @@ import { import { Textarea } from "@/components/ui/textarea" import { useCreateMcpIntegration, + useDeleteMcpIntegration, useGetMcpIntegration, useIntegrations, useUpdateMcpIntegration, } from "@/lib/hooks" +import { isMcpProvider } from "@/lib/integrations" import { cn } from "@/lib/utils" import { useWorkspaceId } from "@/providers/workspace-id" -const SERVER_TYPES = [ - { - value: "http", - label: "URL (HTTP/SSE)", - description: "Connect to an MCP server via HTTP or SSE endpoint", - }, - { - value: "stdio", - label: "Stdio", - description: "Run a command that spawns an MCP server (e.g., npx)", - }, -] as const - -const AUTH_TYPES = [ - { - value: "OAUTH2", - label: "OAuth 2.0", - description: "Use existing OAuth integration (MCP standard)", - }, - { - value: "CUSTOM", - label: "Custom", - description: "API key, bearer token, or custom headers (JSON)", - }, - { - value: "NONE", - label: "No Authentication", - description: "No authentication required (for self-hosted)", - }, -] as const - -const ALLOWED_COMMANDS = ["npx", "uvx", "python", "python3", "node"] as const - -function isAllowedCommand( - command: string -): command is (typeof ALLOWED_COMMANDS)[number] { - return ALLOWED_COMMANDS.includes(command as (typeof ALLOWED_COMMANDS)[number]) -} - -function isValidHeadersJson(value: string): boolean { - try { - const parsed = JSON.parse(value) as unknown - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) - ) { - return false - } - const headers = parsed as Record - for (const headerValue of Object.values(headers)) { - if (typeof headerValue !== "string") { - return false - } - } - return true - } catch { - return false - } -} - -const formSchema = z - .object({ - name: z - .string() - .trim() - .min(3, { message: "Name must be at least 3 characters long" }) - .max(255, { message: "Name must be 255 characters or fewer" }), - description: z - .string() - .trim() - .max(512, { message: "Description must be 512 characters or fewer" }) - .optional() - .or(z.literal("")), - // Server type - server_type: z.enum(["http", "stdio"]), - // HTTP-type fields - server_uri: z.string().trim().optional().or(z.literal("")), - auth_type: z.enum(["OAUTH2", "CUSTOM", "NONE"]), - oauth_integration_id: z.string().uuid().optional().or(z.literal("")), - custom_credentials: z.string().trim().optional().or(z.literal("")), - // Stdio-type fields - stdio_command: z.string().trim().optional().or(z.literal("")), - stdio_args: z.array( - z.object({ - value: z.string(), - }) - ), - stdio_env: z.string().trim().optional().or(z.literal("")), - // General fields - timeout: z.coerce.number().int().min(1).max(300).optional(), - }) - // HTTP-type validation - .refine( - (data) => { - if (data.server_type === "http") { - if (!data.server_uri || data.server_uri.trim() === "") { - return false - } - try { - new URL(data.server_uri) - return true - } catch { - return false - } - } - return true - }, - { - message: "Valid server URL is required for HTTP-type servers", - path: ["server_uri"], - } - ) - .refine( - (data) => { - if (data.server_type === "http" && data.auth_type === "OAUTH2") { - return !!data.oauth_integration_id && data.oauth_integration_id !== "" - } - return true - }, - { - message: "OAuth integration is required for OAuth 2.0 authentication", - path: ["oauth_integration_id"], - } - ) - .refine( - (data) => { - if (data.server_type === "http" && data.auth_type === "CUSTOM") { - if (!data.custom_credentials || data.custom_credentials.trim() === "") { - return false - } - return isValidHeadersJson(data.custom_credentials) - } - return true - }, - { - message: - "Custom credentials must be a valid JSON object with string values", - path: ["custom_credentials"], - } - ) - .refine( - (data) => { - if ( - data.server_type === "http" && - data.auth_type === "OAUTH2" && - data.custom_credentials && - data.custom_credentials.trim() !== "" - ) { - return isValidHeadersJson(data.custom_credentials) - } - return true - }, - { - message: - "Additional headers must be a valid JSON object with string values", - path: ["custom_credentials"], - } - ) - // Stdio-type validation - .refine( - (data) => { - if (data.server_type === "stdio") { - if (!data.stdio_command || data.stdio_command.trim() === "") { - return false - } - const cmd = data.stdio_command.trim() - return isAllowedCommand(cmd) - } - return true - }, - { - message: `Command must be one of: ${ALLOWED_COMMANDS.join(", ")}`, - path: ["stdio_command"], - } - ) - .refine( - (data) => { - if ( - data.server_type === "stdio" && - data.stdio_env && - data.stdio_env.trim() !== "" - ) { - try { - const parsed = JSON.parse(data.stdio_env) as unknown - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) - ) { - return false - } - // Validate all values are strings (API expects Record) - for (const value of Object.values(parsed)) { - if (typeof value !== "string") { - return false - } - } - return true - } catch { - return false - } - } - return true - }, - { - message: - "Environment variables must be a valid JSON object with string values only", - path: ["stdio_env"], - } - ) - -type MCPIntegrationFormValues = z.infer - -const DEFAULT_VALUES: MCPIntegrationFormValues = { - name: "", - description: "", - server_type: "http", - server_uri: "", - auth_type: "NONE", - oauth_integration_id: "", - custom_credentials: "", - stdio_command: "", - stdio_args: [], - stdio_env: "", - timeout: 30, -} - export function MCPIntegrationDialog({ triggerProps, mcpIntegrationId, @@ -296,12 +90,15 @@ export function MCPIntegrationDialog({ useCreateMcpIntegration(workspaceId) const { updateMcpIntegration, updateMcpIntegrationIsPending } = useUpdateMcpIntegration(workspaceId) + const { deleteMcpIntegration, deleteMcpIntegrationIsPending } = + useDeleteMcpIntegration(workspaceId) const { integrations, providers, integrationsIsLoading } = useIntegrations(workspaceId) const { mcpIntegration, mcpIntegrationIsLoading } = useGetMcpIntegration( workspaceId, mcpIntegrationId ?? null ) + const canDelete = useScopeCheck("integration:delete") === true const [internalOpen, setInternalOpen] = useState(false) const [isEditHydrated, setIsEditHydrated] = useState(false) const open = controlledOpen ?? internalOpen @@ -316,8 +113,8 @@ export function MCPIntegrationDialog({ }, [mcpIntegrationId, controlledOpen]) const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: DEFAULT_VALUES, + resolver: zodResolver(mcpIntegrationFormSchema), + defaultValues: MCP_INTEGRATION_FORM_DEFAULTS, }) const { fields: stdioArgFields, @@ -400,7 +197,7 @@ export function MCPIntegrationDialog({ replaceStdioArgs(hydratedStdioArgs) setIsEditHydrated(true) } else { - form.reset(DEFAULT_VALUES) + form.reset(MCP_INTEGRATION_FORM_DEFAULTS) replaceStdioArgs([]) setIsEditHydrated(false) } @@ -928,7 +725,7 @@ export function MCPIntegrationDialog({ ) if ( !integration || - integration.provider_id.endsWith("_mcp") + isMcpProvider(integration.provider_id) ) return null const provider = providers?.find( @@ -953,7 +750,7 @@ export function MCPIntegrationDialog({ ?.filter( (int) => int.status === "connected" && - !int.provider_id.endsWith("_mcp") + !isMcpProvider(int.provider_id) ) .map((integration) => { const provider = providers?.find( @@ -989,7 +786,7 @@ export function MCPIntegrationDialog({ integrations.filter( (int) => int.status === "connected" && - !int.provider_id.endsWith("_mcp") + !isMcpProvider(int.provider_id) ).length === 0) && (
No OAuth integrations available @@ -1045,8 +842,56 @@ export function MCPIntegrationDialog({ /> )} - -
+ + {isEditMode && mcpIntegrationId && canDelete ? ( + + + + + + + + Remove MCP server? + + + Agents will no longer be able to call{" "} + + {mcpIntegration?.name ?? "this server"} + + . + + + + Cancel + { + event.preventDefault() + await deleteMcpIntegration(mcpIntegrationId) + handleOpenChange(false) + }} + > + {deleteMcpIntegrationIsPending && ( + + )} + Remove + + + + + ) : ( + + )} +
+ + + {supportsReauthorize ? ( + + reauthorizeMutation.mutate({ providerId }) + } + disabled={reauthorizeMutation.isPending} + > + + Reauthorize + + ) : null} + {supportsTest ? ( + + testMutation.mutate({ providerId, grantType }) + } + disabled={testMutation.isPending} + > + + Test + + ) : null} + + { + event.preventDefault() + setConfirmDisconnectOpen(true) + }} + > + + Disconnect + + + + ) : null} +
+ ) : ( +

+ Not connected yet. +

+ )} + + +
+

Configuration

@@ -162,20 +320,18 @@ export function OAuthIntegrationDetailsDialog({
)}
-
+ -
-

Scopes

+
+

Scopes

{hasScopes ? ( <> {requestedScopes.length > 0 && (
-
- - Requested scopes - -
+ + Requested +
{requestedScopes.map((scope) => ( 0 && (
-
- - Granted scopes - -
+ + Granted +
{grantedScopes.map((scope) => ( )}
-
+
)} + + + Are you sure you want to disconnect from{" "} + {providerName}? + + } + confirmLabel="Disconnect" + isPending={disconnectMutation.isPending} + onConfirm={async () => { + await disconnectMutation.mutateAsync({ providerId, grantType }) + setConfirmDisconnectOpen(false) + onOpenChange(false) + }} + /> ) } + +function DetailsSkeleton() { + return ( +
+
+ +
+
+ +
+ + +
+
+ +
+
+ +
+ +
+ {[ + "client-id", + "client-secret", + "auth-endpoint", + "token-endpoint", + ].map((key) => ( +
+ + +
+ ))} +
+
+ +
+ +
+ {[14, 18, 12, 16, 20].map((width) => ( + + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/nav/controls-header.tsx b/frontend/src/components/nav/controls-header.tsx index f1f451c9ce..25dd86cab5 100644 --- a/frontend/src/components/nav/controls-header.tsx +++ b/frontend/src/components/nav/controls-header.tsx @@ -12,14 +12,14 @@ import { Flag, Flame, FolderIcon, + KeyRound, ListIcon, - Lock, + LockKeyhole, MessageSquare, MousePointerClickIcon, PanelRight, PenLine, Plus, - Sparkles, TagsIcon, Trash2, User, @@ -75,7 +75,6 @@ import { } from "@/components/dashboard/workflows-catalog-view-toggle" import { DynamicLucideIcon } from "@/components/dynamic-lucide-icon" import { CreateCustomProviderDialog } from "@/components/integrations/create-custom-provider-dialog" -import { MCPIntegrationDialog } from "@/components/integrations/mcp-integration-dialog" import { Spinner } from "@/components/loading/spinner" import { MembersViewMode, @@ -132,6 +131,7 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, @@ -343,7 +343,9 @@ function TablesActions() { } function IntegrationsActions() { - const [activeDialog, setActiveDialog] = useState<"oauth" | "mcp" | null>(null) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [credentialDialogOpen, setCredentialDialogOpen] = useState(false) + const canCreateSecrets = useScopeCheck("secret:create") === true return ( <> @@ -363,34 +365,37 @@ function IntegrationsActions() { [&_[data-radix-collection-item]]:gap-2 " > - setActiveDialog("oauth")}> - -
- OAuth provider - - Add a custom OAuth 2.0 provider - -
-
- setActiveDialog("mcp")}> - -
- MCP integration - - Connect to an MCP server - -
-
+ + {canCreateSecrets ? ( + setCredentialDialogOpen(true)}> + +
+ Key-value credentials + + Store API keys and tokens + +
+
+ ) : null} + setOauthDialogOpen(true)}> + +
+ Custom OAuth provider + + Configure OAuth app credentials + +
+
+
- setActiveDialog(open ? "oauth" : null)} - hideTrigger + - setActiveDialog(open ? "mcp" : null)} + @@ -1547,6 +1552,11 @@ function MembersActions({ view }: { view: MembersViewMode }) { function CredentialsActions() { const [dialogOpen, setDialogOpen] = useState(false) + const canCreateSecrets = useScopeCheck("secret:create") + + if (canCreateSecrets !== true) { + return null + } return ( <> @@ -1596,6 +1606,35 @@ function ServiceAccountsActions() { ) } +function McpServersActions() { + const canCreate = useScopeCheck("integration:create") + const pathname = usePathname() + const searchParams = useSearchParams() + const router = useRouter() + + if (canCreate !== true || !pathname) { + return null + } + + return ( + + ) +} + function McpAccessActions() { const canReadWorkspace = useScopeCheck("workspace:read") const pathname = usePathname() @@ -1977,6 +2016,13 @@ function getPageConfig( } } + if (pagePath.startsWith("/mcp-servers")) { + return { + title: "MCP servers", + actions: , + } + } + if (pagePath.startsWith("/service-accounts")) { return { title: "Service accounts", diff --git a/frontend/src/components/sidebar/app-sidebar.tsx b/frontend/src/components/sidebar/app-sidebar.tsx index fb85b2025f..bab5b0a498 100644 --- a/frontend/src/components/sidebar/app-sidebar.tsx +++ b/frontend/src/components/sidebar/app-sidebar.tsx @@ -13,6 +13,7 @@ import { type LucideIcon, MousePointerClickIcon, Pyramid, + Sparkles, Table2Icon, TerminalIcon, UsersIcon, @@ -191,6 +192,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { isActive: pathname?.startsWith(`${basePath}/integrations`), visible: canViewIntegrations === true, }, + { + title: "MCP servers", + url: `${basePath}/mcp-servers`, + icon: Sparkles, + isActive: pathname?.startsWith(`${basePath}/mcp-servers`), + visible: canViewIntegrations === true, + }, { title: "Skills", url: `${basePath}/skills`, diff --git a/frontend/src/hooks/use-integration-actions.ts b/frontend/src/hooks/use-integration-actions.ts new file mode 100644 index 0000000000..84629c0c65 --- /dev/null +++ b/frontend/src/hooks/use-integration-actions.ts @@ -0,0 +1,133 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import type { OAuthGrantType } from "@/client" +import { + integrationsConnectProvider, + integrationsDisconnectIntegration, + integrationsTestConnection, +} from "@/client" +import { toast } from "@/components/ui/use-toast" +import type { TracecatApiError } from "@/lib/errors" +import { integrationKeys } from "@/lib/integrations" + +interface ProviderRef { + providerId: string + grantType: OAuthGrantType +} + +/** + * Return an invalidator that refreshes every query that can change when an + * OAuth integration's connection state changes. + * + * Connect/disconnect/test all flip the same conceptual surface — the + * provider's `integration_status`, the OAuth integration row, the MCP + * integrations list (since platform MCP rows are auto-derived from MCP OAuth + * providers). Centralized here so call sites don't drift apart on what to + * invalidate. + */ +function useInvalidateIntegrationQueries(workspaceId: string) { + const queryClient = useQueryClient() + return ({ providerId, grantType }: ProviderRef) => { + queryClient.invalidateQueries({ + queryKey: integrationKeys.integration(providerId, workspaceId, grantType), + }) + queryClient.invalidateQueries({ + queryKey: integrationKeys.providers(workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: integrationKeys.integrations(workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: ["mcp-integrations", workspaceId], + }) + } +} + +/** + * Start an OAuth authorization flow for the given provider. + * + * On success the browser is redirected to the provider's auth URL — the + * mutation never resolves visibly because navigation happens first. + */ +export function useConnectProvider(workspaceId: string) { + return useMutation({ + mutationFn: async ({ providerId }: Pick) => + await integrationsConnectProvider({ providerId, workspaceId }), + onSuccess: (result) => { + window.location.href = result.auth_url + }, + onError: (error: TracecatApiError) => { + toast({ + title: "Failed to start OAuth", + description: String(error.body?.detail ?? error.message), + variant: "destructive", + }) + }, + }) +} + +/** + * Disconnect a connected OAuth integration. Invalidates all related queries + * on success so dependent UI flips back to the not-connected state. + */ +export function useDisconnectProvider(workspaceId: string) { + const invalidate = useInvalidateIntegrationQueries(workspaceId) + return useMutation({ + mutationFn: async ({ providerId, grantType }: ProviderRef) => + await integrationsDisconnectIntegration({ + providerId, + workspaceId, + grantType, + }), + onSuccess: (_, variables) => { + invalidate(variables) + toast({ + title: "Disconnected", + description: "Successfully disconnected from provider", + }) + }, + onError: (error: TracecatApiError) => { + toast({ + title: "Failed to disconnect", + description: `${error.body?.detail ?? error.message}`, + variant: "destructive", + }) + }, + }) +} + +/** + * Test that a configured integration's credentials still work. Used both as + * a verification action and as a "reconnect" for client-credentials grants + * that have no interactive OAuth flow. + */ +export function useTestProvider(workspaceId: string) { + const invalidate = useInvalidateIntegrationQueries(workspaceId) + return useMutation({ + mutationFn: async ({ providerId }: ProviderRef) => + await integrationsTestConnection({ providerId, workspaceId }), + onSuccess: (result, variables) => { + invalidate(variables) + if (result.success) { + toast({ + title: "Connection successful", + description: result.message, + }) + } else { + toast({ + title: "Connection failed", + description: result.error || result.message, + variant: "destructive", + }) + } + }, + onError: (error: TracecatApiError) => { + toast({ + title: "Test failed", + description: `${error.body?.detail ?? error.message}`, + variant: "destructive", + }) + }, + }) +} diff --git a/frontend/src/lib/hooks.tsx b/frontend/src/lib/hooks.tsx index da7d7bf8f3..a38f175cee 100644 --- a/frontend/src/lib/hooks.tsx +++ b/frontend/src/lib/hooks.tsx @@ -142,6 +142,7 @@ import { type MCPIntegrationCreate, type MCPIntegrationRead, type MCPIntegrationUpdate, + type McpIntegrationsListMcpIntegrationsData, type ModelCredentialCreate, type ModelCredentialUpdate, mcpIntegrationsCreateMcpIntegration, @@ -4607,15 +4608,26 @@ export function useCreateMcpIntegration(workspaceId: string) { } } -export function useListMcpIntegrations(workspaceId: string) { +/** + * List MCP integrations for a workspace. + * + * Pass `source` to restrict results to either platform-managed rows + * (auto-created by an MCP OAuth provider) or workspace-authored rows. Omit + * `source` to get every row, which is what agent presets and runtime + * resolution need to look up an MCP integration by ID. + */ +export function useListMcpIntegrations( + workspaceId: string, + source?: McpIntegrationsListMcpIntegrationsData["source"] +) { const { data: mcpIntegrations, isLoading: mcpIntegrationsIsLoading, error: mcpIntegrationsError, } = useQuery({ - queryKey: ["mcp-integrations", workspaceId], + queryKey: ["mcp-integrations", workspaceId, source], queryFn: async () => - await mcpIntegrationsListMcpIntegrations({ workspaceId }), + await mcpIntegrationsListMcpIntegrations({ workspaceId, source }), enabled: Boolean(workspaceId), staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, diff --git a/frontend/src/lib/integrations.ts b/frontend/src/lib/integrations.ts new file mode 100644 index 0000000000..749b22d78b --- /dev/null +++ b/frontend/src/lib/integrations.ts @@ -0,0 +1,40 @@ +import type { + McpIntegrationsListMcpIntegrationsData, + OAuthGrantType, +} from "@/client" + +/** + * Whether a provider ID belongs to a platform-shipped MCP auth provider. + * + * Mirrors the backend `MCPAuthProvider` subclass check. All MCPAuthProvider + * IDs end with the `_mcp` suffix by convention; if a future MCPAuthProvider + * deviates from that, update both this predicate and the backend's + * `_is_platform_managed_mcp_integration` check. + * + * Custom OAuth providers can also end in `_mcp`, but they are prefixed with + * `custom_` and should remain on the integrations page. + */ +export function isMcpProvider(providerId: string): boolean { + return providerId.endsWith("_mcp") && !providerId.startsWith("custom_") +} + +/** + * Centralized React Query keys for integration data. + * + * Keeping these in one place avoids invalidation drift: when a mutation needs + * to invalidate the OAuth integrations list and the providers list, it can + * reference the keys by name rather than re-typing the tuple. + */ +export const integrationKeys = { + providers: (workspaceId: string) => ["providers", workspaceId] as const, + integrations: (workspaceId: string) => ["integrations", workspaceId] as const, + integration: ( + providerId: string, + workspaceId: string, + grantType: OAuthGrantType + ) => ["integration", providerId, workspaceId, grantType] as const, + mcpIntegrations: ( + workspaceId: string, + source?: McpIntegrationsListMcpIntegrationsData["source"] + ) => ["mcp-integrations", workspaceId, source] as const, +} as const diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index 363e135739..b9d78d925a 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -1,5 +1,25 @@ +import { formatDistanceToNowStrict } from "date-fns" import { z } from "zod" +/** + * Format a timestamp as a relative-to-now string, e.g. "3 minutes ago". + * + * Returns `null` when the input is missing or not a valid date, so callers + * can render conditionally without wrapping in try/catch. + */ +export function formatRelative( + value: string | Date | null | undefined +): string | null { + if (!value) return null + try { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return null + return formatDistanceToNowStrict(date, { addSuffix: true }) + } catch { + return null + } +} + // Ensure all values are positive // Finally validate that at least one component is present export const durationSchema = z diff --git a/frontend/tests/integrations.test.ts b/frontend/tests/integrations.test.ts new file mode 100644 index 0000000000..0ff408224d --- /dev/null +++ b/frontend/tests/integrations.test.ts @@ -0,0 +1,9 @@ +import { isMcpProvider } from "@/lib/integrations" + +describe("integration helpers", () => { + it("classifies platform MCP providers without hiding custom OAuth providers", () => { + expect(isMcpProvider("github_mcp")).toBe(true) + expect(isMcpProvider("custom_acme_mcp")).toBe(false) + expect(isMcpProvider("custom_acme")).toBe(false) + }) +}) diff --git a/frontend/tests/oauth-integration-details-dialog.test.tsx b/frontend/tests/oauth-integration-details-dialog.test.tsx new file mode 100644 index 0000000000..683d7316ce --- /dev/null +++ b/frontend/tests/oauth-integration-details-dialog.test.tsx @@ -0,0 +1,193 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import type { IntegrationRead, OAuthGrantType, ProviderRead } from "@/client" +import { OAuthIntegrationDetailsDialog } from "@/components/integrations/oauth-integration-details-dialog" +import { + useConnectProvider, + useDisconnectProvider, + useTestProvider, +} from "@/hooks/use-integration-actions" +import { useIntegrationProvider } from "@/lib/hooks" + +jest.mock("@/components/confirm-destructive-dialog", () => ({ + ConfirmDestructiveDialog: () => null, +})) + +jest.mock("@/components/icons", () => ({ + ProviderIcon: () => , +})) + +jest.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean + onOpenChange?: (open: boolean) => void + children: ReactNode + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: ReactNode }) => ( +

{children}

+ ), + DialogHeader: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})) + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + disabled, + onClick, + }: { + children: ReactNode + disabled?: boolean + onClick?: () => void + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => ( + <>{children} + ), +})) + +jest.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +jest.mock("@/hooks/use-integration-actions", () => ({ + useConnectProvider: jest.fn(), + useDisconnectProvider: jest.fn(), + useTestProvider: jest.fn(), +})) + +jest.mock("@/lib/hooks", () => ({ + useIntegrationProvider: jest.fn(), +})) + +jest.mock("@/providers/workspace-id", () => ({ + useWorkspaceId: () => "workspace-1", +})) + +const mockUseIntegrationProvider = + useIntegrationProvider as jest.MockedFunction +const mockUseConnectProvider = useConnectProvider as jest.MockedFunction< + typeof useConnectProvider +> +const mockUseDisconnectProvider = useDisconnectProvider as jest.MockedFunction< + typeof useDisconnectProvider +> +const mockUseTestProvider = useTestProvider as jest.MockedFunction< + typeof useTestProvider +> + +const provider: ProviderRead = { + grant_type: "authorization_code", + metadata: { + id: "slack", + name: "Slack", + description: "Slack OAuth provider", + }, + scopes: { default: [] }, + config_schema: { json_schema: {} }, + integration_status: "connected", + default_authorization_endpoint: null, + default_token_endpoint: null, +} + +const integration: IntegrationRead = { + id: "integration-1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + user_id: "user-1", + provider_id: "slack", + authorization_endpoint: null, + token_endpoint: null, + token_type: "Bearer", + expires_at: null, + client_id: "client-id", + granted_scopes: [], + requested_scopes: [], + status: "connected", + is_expired: false, +} + +function setupMocks(grantType: OAuthGrantType) { + mockUseIntegrationProvider.mockReturnValue({ + provider: { ...provider, grant_type: grantType }, + providerIsLoading: false, + providerError: null, + integration, + integrationIsLoading: false, + integrationError: null, + } as unknown as ReturnType) + + const mutation = { + isPending: false, + mutate: jest.fn(), + mutateAsync: jest.fn(), + } + + mockUseConnectProvider.mockReturnValue( + mutation as unknown as ReturnType + ) + mockUseDisconnectProvider.mockReturnValue( + mutation as unknown as ReturnType + ) + mockUseTestProvider.mockReturnValue( + mutation as unknown as ReturnType + ) +} + +function renderDialog(grantType: OAuthGrantType) { + setupMocks(grantType) + + render( + {}} + canUpdate={true} + /> + ) +} + +describe("OAuthIntegrationDetailsDialog", () => { + it("hides the Test action for authorization-code providers", () => { + renderDialog("authorization_code") + + expect( + screen.queryByRole("button", { name: /test/i }) + ).not.toBeInTheDocument() + expect( + screen.getByRole("button", { name: /reauthorize/i }) + ).toBeInTheDocument() + }) + + it("shows the Test action for client-credentials providers", () => { + renderDialog("client_credentials") + + expect(screen.getByRole("button", { name: /test/i })).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: /reauthorize/i }) + ).not.toBeInTheDocument() + }) +}) diff --git a/tests/unit/test_integration_oauth_redirect.py b/tests/unit/test_integration_oauth_redirect.py new file mode 100644 index 0000000000..358dd8f48f --- /dev/null +++ b/tests/unit/test_integration_oauth_redirect.py @@ -0,0 +1,42 @@ +"""Tests for integration OAuth callback redirect targets.""" + +import uuid + +import pytest + +from tracecat import config +from tracecat.integrations.providers.github.mcp import GitHubMCPProvider +from tracecat.integrations.providers.github.oauth import GitHubOAuthProvider +from tracecat.integrations.router import _oauth_callback_redirect_url + + +def test_oauth_callback_redirect_url_uses_integrations_for_oauth_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(config, "TRACECAT__PUBLIC_APP_URL", "https://tracecat.test") + workspace_id = uuid.uuid4() + + redirect_url = _oauth_callback_redirect_url( + provider_impl=GitHubOAuthProvider, + workspace_id=workspace_id, + ) + + assert ( + redirect_url == f"https://tracecat.test/workspaces/{workspace_id}/integrations" + ) + + +def test_oauth_callback_redirect_url_uses_mcp_servers_for_mcp_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(config, "TRACECAT__PUBLIC_APP_URL", "https://tracecat.test") + workspace_id = uuid.uuid4() + + redirect_url = _oauth_callback_redirect_url( + provider_impl=GitHubMCPProvider, + workspace_id=workspace_id, + ) + + assert ( + redirect_url == f"https://tracecat.test/workspaces/{workspace_id}/mcp-servers" + ) diff --git a/tests/unit/test_mcp_integrations.py b/tests/unit/test_mcp_integrations.py index 516885b491..29a03d1886 100644 --- a/tests/unit/test_mcp_integrations.py +++ b/tests/unit/test_mcp_integrations.py @@ -21,7 +21,12 @@ from tracecat.agent.preset.service import AgentPresetService from tracecat.auth.types import Role from tracecat.authz.scopes import ADMIN_SCOPES -from tracecat.db.models import AgentPreset, MCPIntegration, OAuthIntegration +from tracecat.db.models import ( + AgentPreset, + AgentPresetVersion, + MCPIntegration, + OAuthIntegration, +) from tracecat.integrations.enums import MCPAuthType, OAuthGrantType from tracecat.integrations.providers.base import ( DynamicRegistrationResult, @@ -323,6 +328,59 @@ async def test_list_mcp_integrations( integration.name.startswith("Test MCP") for integration in integrations ) + async def test_list_mcp_integrations_source_keeps_matching_user_row_as_workspace( + self, + integration_service: IntegrationService, + ) -> None: + """User-created MCP rows stay workspace-owned even with provider server URI.""" + provider_key = ProviderKey( + id="github_mcp", + grant_type=OAuthGrantType.AUTHORIZATION_CODE, + ) + oauth_integration = await integration_service.store_integration( + provider_key=provider_key, + access_token=SecretStr("test_access_token"), + refresh_token=SecretStr("test_refresh_token"), + expires_in=3600, + ) + + auto_created = await integration_service.session.execute( + select(MCPIntegration).where( + MCPIntegration.workspace_id == integration_service.workspace_id, + MCPIntegration.oauth_integration_id == oauth_integration.id, + ) + ) + platform_created = auto_created.scalars().first() + assert platform_created is not None + server_uri = platform_created.server_uri + assert server_uri is not None + + workspace_created = await integration_service.create_mcp_integration( + params=MCPHttpIntegrationCreate( + name="Workspace-authored Provider MCP", + server_uri=server_uri, + auth_type=MCPAuthType.OAUTH2, + oauth_integration_id=oauth_integration.id, + ) + ) + + platform_integrations = await integration_service.list_mcp_integrations( + source="platform" + ) + workspace_integrations = await integration_service.list_mcp_integrations( + source="workspace" + ) + + assert [integration.id for integration in platform_integrations] == [ + platform_created.id + ] + assert workspace_created.id in { + integration.id for integration in workspace_integrations + } + assert platform_created.id not in { + integration.id for integration in workspace_integrations + } + async def test_update_mcp_integration( self, integration_service: IntegrationService, @@ -392,6 +450,30 @@ async def test_delete_mcp_integration( oauth_integration_id=oauth_integration.id, ) created = await integration_service.create_mcp_integration(params=params) + preset = AgentPreset( + workspace_id=integration_service.workspace_id, + name="Delete MCP preset", + slug="delete-mcp-preset", + model_name="gpt-4o-mini", + model_provider="openai", + mcp_integrations=[str(created.id)], + ) + integration_service.session.add(preset) + await integration_service.session.flush() + preset_id = preset.id + initial_version = AgentPresetVersion( + workspace_id=integration_service.workspace_id, + preset_id=preset_id, + version=1, + model_name=preset.model_name, + model_provider=preset.model_provider, + mcp_integrations=list(preset.mcp_integrations or []), + ) + integration_service.session.add(initial_version) + await integration_service.session.flush() + initial_version_id = initial_version.id + preset.current_version_id = initial_version_id + await integration_service.session.commit() deleted = await integration_service.delete_mcp_integration( mcp_integration_id=created.id @@ -405,6 +487,24 @@ async def test_delete_mcp_integration( ) assert retrieved is None + refreshed_preset_result = await integration_service.session.execute( + select(AgentPreset).where(AgentPreset.id == preset_id) + ) + refreshed_preset = refreshed_preset_result.scalars().one() + assert refreshed_preset.current_version_id != initial_version_id + assert refreshed_preset.mcp_integrations is not None + assert str(created.id) not in refreshed_preset.mcp_integrations + + current_version_result = await integration_service.session.execute( + select(AgentPresetVersion).where( + AgentPresetVersion.id == refreshed_preset.current_version_id + ) + ) + current_version = current_version_result.scalars().one() + assert current_version.version == 2 + assert current_version.mcp_integrations is not None + assert str(created.id) not in current_version.mcp_integrations + async def test_delete_mcp_integration_shared_oauth_keeps_tokens( self, integration_service: IntegrationService, @@ -508,6 +608,153 @@ async def test_delete_mcp_integration_last_reference_disconnects_mcp_provider_oa assert refreshed_oauth.scope is None assert refreshed_oauth.requested_scopes is None + async def test_disconnect_mcp_provider_oauth_removes_auto_created_mcp_integration( + self, + integration_service: IntegrationService, + ) -> None: + """Test disconnecting MCP-provider OAuth only removes its derived MCP row.""" + provider_key = ProviderKey( + id="github_mcp", + grant_type=OAuthGrantType.AUTHORIZATION_CODE, + ) + oauth_integration = await integration_service.store_integration( + provider_key=provider_key, + access_token=SecretStr("test_access_token"), + refresh_token=SecretStr("test_refresh_token"), + expires_in=3600, + ) + + auto_created = await integration_service.session.execute( + select(MCPIntegration).where( + MCPIntegration.workspace_id == integration_service.workspace_id, + MCPIntegration.oauth_integration_id == oauth_integration.id, + ) + ) + mcp_integration = auto_created.scalars().first() + assert mcp_integration is not None + mcp_integration_id = mcp_integration.id + server_uri = mcp_integration.server_uri + assert server_uri is not None + + duplicate_managed_mcp = MCPIntegration( + workspace_id=integration_service.workspace_id, + name="Duplicate GitHub MCP", + slug="github_mcp-1", + server_type="http", + server_uri=server_uri, + auth_type=MCPAuthType.OAUTH2, + oauth_integration_id=oauth_integration.id, + ) + integration_service.session.add(duplicate_managed_mcp) + await integration_service.session.flush() + duplicate_managed_mcp_id = duplicate_managed_mcp.id + + wildcard_collision_mcp = MCPIntegration( + workspace_id=integration_service.workspace_id, + name="Wildcard collision GitHub MCP", + slug="github-mcp-1", + server_type="http", + server_uri=server_uri, + auth_type=MCPAuthType.OAUTH2, + oauth_integration_id=oauth_integration.id, + ) + integration_service.session.add(wildcard_collision_mcp) + await integration_service.session.flush() + wildcard_collision_mcp_id = wildcard_collision_mcp.id + + workspace_created = await integration_service.create_mcp_integration( + params=MCPHttpIntegrationCreate( + name="Workspace-authored MCP", + server_uri=server_uri, + auth_type=MCPAuthType.OAUTH2, + oauth_integration_id=oauth_integration.id, + ) + ) + workspace_created_id = workspace_created.id + + preset = AgentPreset( + workspace_id=integration_service.workspace_id, + name="MCP provider preset", + slug="mcp-provider-preset", + model_name="gpt-4o-mini", + model_provider="openai", + mcp_integrations=[ + str(mcp_integration_id), + str(duplicate_managed_mcp_id), + str(wildcard_collision_mcp_id), + str(workspace_created_id), + ], + ) + integration_service.session.add(preset) + await integration_service.session.flush() + preset_id = preset.id + initial_version = AgentPresetVersion( + workspace_id=integration_service.workspace_id, + preset_id=preset_id, + version=1, + model_name=preset.model_name, + model_provider=preset.model_provider, + mcp_integrations=list(preset.mcp_integrations or []), + ) + integration_service.session.add(initial_version) + await integration_service.session.flush() + initial_version_id = initial_version.id + preset.current_version_id = initial_version_id + await integration_service.session.commit() + + await integration_service.disconnect_integration(integration=oauth_integration) + + refreshed_oauth = await integration_service.session.get( + OAuthIntegration, oauth_integration.id + ) + assert refreshed_oauth is not None + assert refreshed_oauth.provider_id == provider_key.id + assert await integration_service.get_access_token(refreshed_oauth) is None + + deleted_mcp = await integration_service.get_mcp_integration( + mcp_integration_id=mcp_integration_id + ) + assert deleted_mcp is None + deleted_duplicate_mcp = await integration_service.get_mcp_integration( + mcp_integration_id=duplicate_managed_mcp_id + ) + assert deleted_duplicate_mcp is None + + wildcard_collision = await integration_service.get_mcp_integration( + mcp_integration_id=wildcard_collision_mcp_id + ) + assert wildcard_collision is not None + + surviving_mcp = await integration_service.get_mcp_integration( + mcp_integration_id=workspace_created_id + ) + assert surviving_mcp is not None + + refreshed_preset_result = await integration_service.session.execute( + select(AgentPreset).where(AgentPreset.id == preset_id) + ) + refreshed_preset = refreshed_preset_result.scalars().first() + assert refreshed_preset is not None + assert refreshed_preset.mcp_integrations is not None + assert str(mcp_integration_id) not in refreshed_preset.mcp_integrations + assert str(duplicate_managed_mcp_id) not in refreshed_preset.mcp_integrations + assert str(wildcard_collision_mcp_id) in refreshed_preset.mcp_integrations + assert str(workspace_created_id) in refreshed_preset.mcp_integrations + assert refreshed_preset.current_version_id != initial_version_id + + current_version_result = await integration_service.session.execute( + select(AgentPresetVersion).where( + AgentPresetVersion.id == refreshed_preset.current_version_id + ) + ) + current_version = current_version_result.scalars().one() + assert current_version.version == 2 + assert current_version.mcp_integrations is not None + assert str(mcp_integration_id) not in current_version.mcp_integrations + assert str(duplicate_managed_mcp_id) not in current_version.mcp_integrations + assert str(wildcard_collision_mcp_id) in current_version.mcp_integrations + assert str(workspace_created_id) in current_version.mcp_integrations + async def test_delete_mcp_integration_rolls_back_on_disconnect_failure( self, integration_service: IntegrationService, diff --git a/tracecat/integrations/router.py b/tracecat/integrations/router.py index 83a245703d..347c8972bd 100644 --- a/tracecat/integrations/router.py +++ b/tracecat/integrations/router.py @@ -30,6 +30,7 @@ from tracecat.integrations.providers import all_providers from tracecat.integrations.providers.base import ( AuthorizationCodeOAuthProvider, + MCPAuthProvider, ServiceAccountOAuthProvider, ) from tracecat.integrations.schemas import ( @@ -42,6 +43,7 @@ IntegrationUpdate, MCPIntegrationCreate, MCPIntegrationRead, + MCPIntegrationSource, MCPIntegrationUpdate, ProviderKey, ProviderRead, @@ -68,6 +70,17 @@ """Routes for managing MCP integrations.""" +def _oauth_callback_redirect_url( + *, + provider_impl: type[AuthorizationCodeOAuthProvider], + workspace_id: uuid.UUID, +) -> str: + target_page = ( + "mcp-servers" if issubclass(provider_impl, MCPAuthProvider) else "integrations" + ) + return f"{config.TRACECAT__PUBLIC_APP_URL}/workspaces/{workspace_id}/{target_page}" + + @oauth_router.get("/callback") async def oauth_callback( *, @@ -247,9 +260,15 @@ async def oauth_callback( detail="Provider returned insecure OAuth endpoints", ) from exc logger.info("Returning OAuth callback", status="connected", provider=key.id) + if role.workspace_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Workspace ID is required", + ) - redirect_url = ( - f"{config.TRACECAT__PUBLIC_APP_URL}/workspaces/{role.workspace_id}/integrations" + redirect_url = _oauth_callback_redirect_url( + provider_impl=provider_impl, + workspace_id=role.workspace_id, ) return IntegrationOAuthCallback( status="connected", @@ -858,8 +877,17 @@ async def create_mcp_integration( async def list_mcp_integrations( role: WorkspaceActorRouteRole, session: AsyncDBSession, + source: Annotated[ + MCPIntegrationSource | None, + Query( + description=( + "Restrict results to platform-managed or workspace-authored " + "MCP integrations. Defaults to all rows." + ), + ), + ] = None, ) -> list[MCPIntegrationRead]: - """List all MCP integrations for the workspace.""" + """List MCP integrations for the workspace, optionally filtered by source.""" if role.workspace_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -867,7 +895,7 @@ async def list_mcp_integrations( ) svc = IntegrationService(session, role=role) - integrations = await svc.list_mcp_integrations() + integrations = await svc.list_mcp_integrations(source=source) return [ MCPIntegrationRead( diff --git a/tracecat/integrations/schemas.py b/tracecat/integrations/schemas.py index 4f0fe06f8e..1ad842273d 100644 --- a/tracecat/integrations/schemas.py +++ b/tracecat/integrations/schemas.py @@ -524,6 +524,16 @@ def _validate_server_uri(cls, value: str | None) -> str | None: return value +MCPIntegrationSource = Literal["platform", "workspace"] +"""Provenance of an MCP integration. + +- ``platform``: auto-created by a platform-shipped MCP auth provider (the + lifecycle is owned by the OAuth flow for an ``MCPAuthProvider``). +- ``workspace``: explicitly created by a workspace user via the MCP servers + page or API. +""" + + class MCPIntegrationRead(BaseModel): """Response model for MCP integration.""" diff --git a/tracecat/integrations/service.py b/tracecat/integrations/service.py index c9781ce20e..3d5fd16202 100644 --- a/tracecat/integrations/service.py +++ b/tracecat/integrations/service.py @@ -8,6 +8,7 @@ from uuid import uuid4 import orjson +import sqlalchemy as sa from pydantic import SecretStr from slugify import slugify from sqlalchemy import and_, or_, select, update @@ -38,6 +39,7 @@ from tracecat.integrations.schemas import ( CustomOAuthProviderCreate, MCPIntegrationCreate, + MCPIntegrationSource, MCPIntegrationUpdate, ProviderConfig, ProviderKey, @@ -57,6 +59,10 @@ class IntegrationService(BaseWorkspaceService): service_name = "integrations" + @staticmethod + def _escape_like_pattern(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_") + @staticmethod def _validate_https_endpoint( endpoint: str | None, *, field_name: str @@ -484,9 +490,19 @@ def resolve_endpoint( @require_scope("integration:update") async def disconnect_integration(self, *, integration: OAuthIntegration) -> None: """Disconnect a user's integration for a specific provider.""" - self._disconnect_integration_state(integration=integration) - self.session.add(integration) - await self.session.commit() + try: + if await self._is_mcp_lifecycle_owned_oauth_integration( + integration=integration + ): + await self._delete_mcp_integrations_for_oauth_integration( + integration=integration + ) + self._disconnect_integration_state(integration=integration) + self.session.add(integration) + await self.session.commit() + except Exception: + await self.session.rollback() + raise def _disconnect_integration_state(self, *, integration: OAuthIntegration) -> None: """Apply disconnected token state to an integration without committing.""" @@ -1118,6 +1134,112 @@ async def _is_mcp_lifecycle_owned_oauth_integration( ) return bool(provider_impl and issubclass(provider_impl, MCPAuthProvider)) + async def _delete_mcp_integrations_for_oauth_integration( + self, *, integration: OAuthIntegration + ) -> int: + """Delete MCP rows backed by a lifecycle-owned OAuth integration.""" + provider_impl = await self.resolve_provider_impl( + provider_key=ProviderKey( + id=integration.provider_id, + grant_type=integration.grant_type, + ) + ) + if provider_impl is None or not issubclass(provider_impl, MCPAuthProvider): + return 0 + provider_impl = cast(type[MCPAuthProvider], provider_impl) + + provider_slug = provider_impl.id + escaped_provider_slug = self._escape_like_pattern(provider_slug) + candidate_mcp_integrations = ( + select( + MCPIntegration.id.label("id"), + sa.cast(MCPIntegration.id, sa.String).label("id_str"), + ) + .where( + MCPIntegration.workspace_id == self.workspace_id, + MCPIntegration.oauth_integration_id == integration.id, + MCPIntegration.server_type == "http", + MCPIntegration.auth_type == MCPAuthType.OAUTH2, + MCPIntegration.server_uri == provider_impl.mcp_server_uri, + or_( + MCPIntegration.slug == provider_slug, + and_( + MCPIntegration.slug.like( + f"{escaped_provider_slug}-%", escape="\\" + ), + sa.func.substring( + MCPIntegration.slug, + len(provider_slug) + 2, + ).op("~")(r"^\d+$"), + ), + ), + ) + .cte("candidate_mcp_integrations") + ) + + candidate_ids = select( + sa.func.coalesce( + sa.func.array_agg(candidate_mcp_integrations.c.id_str), + sa.cast(sa.literal([]), sa.ARRAY(sa.String())), + ) + ).scalar_subquery() + candidate_exists = select(candidate_mcp_integrations.c.id).exists() + + pruned_preset_ids = ( + await self.session.scalars( + update(AgentPreset) + .where( + AgentPreset.workspace_id == self.workspace_id, + AgentPreset.mcp_integrations.isnot(None), + candidate_exists, + AgentPreset.mcp_integrations.op("?|")(candidate_ids), + ) + .values( + mcp_integrations=AgentPreset.mcp_integrations.op("-")(candidate_ids) + ) + .returning(AgentPreset.id) + .execution_options(synchronize_session="fetch") + ) + ).all() + await self._version_pruned_agent_presets(preset_ids=pruned_preset_ids) + + deleted = await self.session.scalars( + sa.delete(MCPIntegration) + .where(MCPIntegration.id.in_(select(candidate_mcp_integrations.c.id))) + .returning(MCPIntegration.id) + .execution_options(synchronize_session="fetch") + ) + return len(deleted.all()) + + async def _version_pruned_agent_presets( + self, *, preset_ids: Sequence[uuid.UUID] + ) -> None: + """Create current versions for presets whose MCP refs were pruned.""" + if not preset_ids: + return + + from tracecat.agent.preset.service import AgentPresetService + + preset_service = AgentPresetService(self.session, role=self.role) + presets = await self.session.scalars( + select(AgentPreset) + .where( + AgentPreset.workspace_id == self.workspace_id, + AgentPreset.id.in_(preset_ids), + ) + .order_by(AgentPreset.id) + .with_for_update() + ) + + for preset in presets: + version = await preset_service._create_version_from_preset( + preset, + preset_locked=True, + ) + preset.current_version_id = version.id + self.session.add(preset) + await self.session.flush() + async def _mcp_integration_slug_taken(self, slug: str) -> bool: """Check if an MCP integration slug is already taken.""" statement = select(MCPIntegration).where( @@ -1216,13 +1338,75 @@ async def create_mcp_integration( return mcp_integration - async def list_mcp_integrations(self) -> Sequence[MCPIntegration]: - """List all MCP integrations for the workspace.""" + @staticmethod + def _mcp_integration_uses_provider_server( + mcp_integration: MCPIntegration, provider_impl: type[MCPAuthProvider] + ) -> bool: + return ( + mcp_integration.server_type == "http" + and mcp_integration.auth_type == MCPAuthType.OAUTH2 + and mcp_integration.server_uri == provider_impl.mcp_server_uri + ) + + @staticmethod + def _mcp_integration_has_provider_slug( + mcp_integration: MCPIntegration, provider_impl: type[MCPAuthProvider] + ) -> bool: + provider_slug = provider_impl.id + if mcp_integration.slug == provider_slug: + return True + suffix = mcp_integration.slug.removeprefix(f"{provider_slug}-") + return suffix != mcp_integration.slug and suffix.isdigit() + + def _is_platform_managed_mcp_integration( + self, mcp_integration: MCPIntegration + ) -> bool: + """Whether an MCP integration is owned by the MCP OAuth provider lifecycle. + + Platform-managed rows are auto-created by ``MCPAuthProvider`` flows in + ``_auto_create_mcp_integration_if_needed``. We identify them by their + provider-owned slug and provider server details because workspace users + can create their own MCP rows backed by the same OAuth integration. + """ + oauth_integration = mcp_integration.oauth_integration + if oauth_integration is None: + return False + provider_impl = get_provider_class( + ProviderKey( + id=oauth_integration.provider_id, + grant_type=oauth_integration.grant_type, + ) + ) + return bool( + provider_impl + and issubclass(provider_impl, MCPAuthProvider) + and self._mcp_integration_uses_provider_server( + mcp_integration, + cast(type[MCPAuthProvider], provider_impl), + ) + and self._mcp_integration_has_provider_slug( + mcp_integration, + cast(type[MCPAuthProvider], provider_impl), + ) + ) + + async def list_mcp_integrations( + self, *, source: MCPIntegrationSource | None = None + ) -> Sequence[MCPIntegration]: + """List MCP integrations for the workspace, optionally filtered by source.""" statement = select(MCPIntegration).where( MCPIntegration.workspace_id == self.workspace_id ) result = await self.session.execute(statement) - return result.scalars().all() + integrations = result.scalars().all() + if source is None: + return integrations + want_platform = source == "platform" + return [ + mcp + for mcp in integrations + if self._is_platform_managed_mcp_integration(mcp) == want_platform + ] async def get_mcp_integration( self, *, mcp_integration_id: uuid.UUID @@ -1359,20 +1543,26 @@ async def delete_mcp_integration(self, *, mcp_integration_id: uuid.UUID) -> bool id_str = str(mcp_integration_id) - # Remove stale ID references from agent presets in this workspace - await self.session.execute( - update(AgentPreset) - .where( - and_( - AgentPreset.workspace_id == self.workspace_id, - AgentPreset.mcp_integrations.isnot(None), - AgentPreset.mcp_integrations.contains([id_str]), + try: + pruned_preset_ids = ( + await self.session.scalars( + update(AgentPreset) + .where( + and_( + AgentPreset.workspace_id == self.workspace_id, + AgentPreset.mcp_integrations.isnot(None), + AgentPreset.mcp_integrations.contains([id_str]), + ) + ) + .values( + mcp_integrations=AgentPreset.mcp_integrations.op("-")(id_str) + ) + .returning(AgentPreset.id) + .execution_options(synchronize_session="fetch") ) - ) - .values(mcp_integrations=AgentPreset.mcp_integrations.op("-")(id_str)) - ) + ).all() + await self._version_pruned_agent_presets(preset_ids=pruned_preset_ids) - try: # If backed by an OAuth integration, lock it to serialize deletes for shared refs. oauth_integration = None oauth_integration_id = mcp_integration.oauth_integration_id