From 5171b7b13cf7c124e8c2814ccab609ba28c5ef30 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 05:04:59 -0700 Subject: [PATCH] [eric] split: break oversized Tools children under 300 (ServiceGroup, dialogs, server row, connection + registry hooks) --- .../src/app/pages/Tools/CustomToolCard.tsx | 285 ++------------ .../src/app/pages/Tools/CustomToolConnect.tsx | 82 ++++ .../src/app/pages/Tools/CustomToolDevInfo.tsx | 51 +++ .../src/app/pages/Tools/McpConfigDialog.tsx | 145 +++++++ .../app/pages/Tools/RegistryBrowserDialog.tsx | 191 +-------- .../src/app/pages/Tools/RegistryServerRow.tsx | 205 ++++++++++ frontend/src/app/pages/Tools/ServiceGroup.tsx | 181 +++++++++ frontend/src/app/pages/Tools/ToolDialogs.tsx | 120 +----- .../src/app/pages/Tools/useRegistryBrowser.ts | 173 +++++++++ .../src/app/pages/Tools/useToolConnections.ts | 223 +++++++++++ .../src/app/pages/Tools/useToolsActions.ts | 367 +----------------- 11 files changed, 1133 insertions(+), 890 deletions(-) create mode 100644 frontend/src/app/pages/Tools/CustomToolConnect.tsx create mode 100644 frontend/src/app/pages/Tools/CustomToolDevInfo.tsx create mode 100644 frontend/src/app/pages/Tools/McpConfigDialog.tsx create mode 100644 frontend/src/app/pages/Tools/RegistryServerRow.tsx create mode 100644 frontend/src/app/pages/Tools/ServiceGroup.tsx create mode 100644 frontend/src/app/pages/Tools/useRegistryBrowser.ts create mode 100644 frontend/src/app/pages/Tools/useToolConnections.ts diff --git a/frontend/src/app/pages/Tools/CustomToolCard.tsx b/frontend/src/app/pages/Tools/CustomToolCard.tsx index bd097ca4..c4768603 100644 --- a/frontend/src/app/pages/Tools/CustomToolCard.tsx +++ b/frontend/src/app/pages/Tools/CustomToolCard.tsx @@ -17,17 +17,16 @@ import ExtensionIcon from '@mui/icons-material/Extension'; import SearchIcon from '@mui/icons-material/Search'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import LinkIcon from '@mui/icons-material/Link'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import SettingsIcon from '@mui/icons-material/Settings'; -import BlockIcon from '@mui/icons-material/Block'; -import VisibilityIcon from '@mui/icons-material/Visibility'; import SecurityIcon from '@mui/icons-material/Security'; -import PanToolIcon from '@mui/icons-material/PanTool'; import RefreshIcon from '@mui/icons-material/Refresh'; import { ToolDefinition } from '@/shared/state/toolsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { Integration } from './integrations'; +import ServiceGroup from './ServiceGroup'; +import CustomToolDevInfo from './CustomToolDevInfo'; +import CustomToolConnect from './CustomToolConnect'; interface CustomToolCardProps { tool: ToolDefinition; @@ -87,170 +86,6 @@ const CustomToolCard: React.FC = ({ const hasPerms = serviceNames.length > 0; const totalToolCount = serviceNames.reduce((acc, s) => acc + (services![s].read?.length || 0) + (services![s].write?.length || 0), 0); - const toDisplayName = (name: string, serviceName?: string) => { - let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); - if (serviceName) { - const svcLower = serviceName.toLowerCase(); - const variants = [svcLower, svcLower.replace(/s$/, '')]; - for (const v of variants) { - display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim(); - } - display = display.replace(/\s{2,}/g, ' ').trim(); - } - return display; - }; - - const firstSentence = (desc: string) => { - if (!desc) return ''; - const match = desc.match(/^(.+?(?:\.|$))/); - return match ? match[1].trim() : desc.substring(0, 100); - }; - - const getGroupPolicy = (names: string[]) => { - if (names.length === 0) return 'ask'; - const policies = names.map((n) => perms[n] || 'ask'); - if (policies.every((p) => p === 'always_allow')) return 'always_allow'; - if (policies.every((p) => p === 'deny')) return 'deny'; - if (policies.every((p) => p === 'ask')) return 'ask'; - return 'mixed'; - }; - - const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => ( - e.stopPropagation()}> - onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}> - onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}> - onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}> - - ); - - const ServiceGroup = ({ serviceName, data, isFirstGroup }: { serviceName: string; data: { read?: string[]; write?: string[] }; isFirstGroup?: boolean }) => { - const svcKey = `${tool.id}:${serviceName}`; - const isOpen = expandedServices[svcKey] ?? false; - const allNames = [...(data.read || []), ...(data.write || [])]; - const svcPolicy = getGroupPolicy(allNames); - const count = allNames.length; - const isReddit = - ig?.id === 'reddit' || - tool.name?.toLowerCase() === 'reddit' || - (tool.command || '').toLowerCase().includes('reddit'); - const isYoutube = - ig?.id === 'youtube' || - tool.name?.toLowerCase() === 'youtube' || - (tool.command || '').toLowerCase().includes('youtube'); - const isSubredditsForReddit = - isReddit && /subreddit/i.test(serviceName); - // YouTube marker lands on the first service group since YouTube has no drill-down. - const showPermissionMarker = - isSubredditsForReddit || (isYoutube && isFirstGroup); - - return ( - - setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))} - > - - - {serviceName} - - - - handleGroupPermissionChange(tool.id, allNames, v)} /> - - - - - {(data.read?.length || 0) > 0 && ( - - - - - Read-only - - - handleGroupPermissionChange(tool.id, data.read!, v)} size={14} /> - - {data.read!.map((name) => { - const schemaKey = `${tool.id}:${name}`; - const schema = schemas[name]; - const schemaProps = schema?.properties as Record | undefined; - const schemaRequired = (schema?.required || []) as string[]; - return ( - - devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> - - {toDisplayName(name, serviceName)} - {descriptions[name] && {firstSentence(descriptions[name])}} - - handlePermissionChange(tool.id, name, v)} size={14} /> - - {devMode && expandedSchema === schemaKey && schemaProps && ( - - Input Parameters - {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( - - {pName} - {pDef?.type || 'any'} - {schemaRequired.includes(pName) && } - {pDef?.description && {pDef.description}} - - ))} - - )} - - ); - })} - - )} - {(data.write?.length || 0) > 0 && ( - - - - - Write / delete - - - handleGroupPermissionChange(tool.id, data.write!, v)} size={14} /> - - {data.write!.map((name) => { - const schemaKey = `${tool.id}:${name}`; - const schema = schemas[name]; - const schemaProps = schema?.properties as Record | undefined; - const schemaRequired = (schema?.required || []) as string[]; - return ( - - devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> - - {toDisplayName(name, serviceName)} - {descriptions[name] && {firstSentence(descriptions[name])}} - - handlePermissionChange(tool.id, name, v)} size={14} /> - - {devMode && expandedSchema === schemaKey && schemaProps && ( - - Input Parameters - {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( - - {pName} - {pDef?.type || 'any'} - {schemaRequired.includes(pName) && } - {pDef?.description && {pDef.description}} - - ))} - - )} - - ); - })} - - )} - - - - ); - }; - const isDisabled = tool.enabled === false; // Defensive Reddit detection so onboarding hooks still attach when ig.id lookup fails (legacy/manual installs). @@ -302,51 +137,16 @@ const CustomToolCard: React.FC = ({ {tool.description && {tool.description}} - {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && ( - - )} - {!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && ( - - )} - {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( - - )} - {!isDisabled && ig && tool.auth_status === 'connected' && ( - - } - label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} - size="small" - onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined} - onClick={(e) => e.stopPropagation()} - sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }} - /> - - )} + {ig && ( = ({ ) : ( {serviceNames.map((svc, idx) => ( - + ))} )} - {devMode && isMcp && ( - - - Developer Info - - - - MCP Config - - - {JSON.stringify(tool.mcp_config, null, 2)} - - - - - Auth type: - {tool.auth_type || 'none'} - - - Status: - {tool.auth_status || 'none'} - - {tool.connected_account_email && ( - - Account: - {tool.connected_account_email} - - )} - - {tool.credentials && Object.keys(tool.credentials).length > 0 && ( - - Credentials: - {Object.keys(tool.credentials).map((key) => ( - - ))} - - )} - - )} + {devMode && isMcp && } diff --git a/frontend/src/app/pages/Tools/CustomToolConnect.tsx b/frontend/src/app/pages/Tools/CustomToolConnect.tsx new file mode 100644 index 00000000..b84b5cde --- /dev/null +++ b/frontend/src/app/pages/Tools/CustomToolConnect.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Tooltip from '@mui/material/Tooltip'; +import LinkIcon from '@mui/icons-material/Link'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { Integration } from './integrations'; + +interface CustomToolConnectProps { + tool: ToolDefinition; + ig: Integration | undefined; + isDisabled: boolean; + onOAuthConnect: (toolId: string) => void; + onDeviceCodeConnect: (toolId: string) => void; + onOpenCredentialsDialog: (toolId: string, integration: Integration) => void; + onM365Disconnect: (toolId: string) => void; + onDisconnectIntegration: (toolId: string, integration: Integration) => void; +} + +const CustomToolConnect: React.FC = ({ + tool, ig, isDisabled, + onOAuthConnect: handleOAuthConnect, + onDeviceCodeConnect: handleDeviceCodeConnect, + onOpenCredentialsDialog: openCredentialsDialog, + onM365Disconnect: handleM365Disconnect, + onDisconnectIntegration: handleDisconnectIntegration, +}) => { + const c = useClaudeTokens(); + return ( + <> + {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && ( + + )} + {!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && ( + + )} + {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( + + )} + {!isDisabled && ig && tool.auth_status === 'connected' && ( + + } + label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} + size="small" + onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined} + onClick={(e) => e.stopPropagation()} + sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }} + /> + + )} + + ); +}; + +export default CustomToolConnect; diff --git a/frontend/src/app/pages/Tools/CustomToolDevInfo.tsx b/frontend/src/app/pages/Tools/CustomToolDevInfo.tsx new file mode 100644 index 00000000..62424010 --- /dev/null +++ b/frontend/src/app/pages/Tools/CustomToolDevInfo.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const CustomToolDevInfo: React.FC<{ tool: ToolDefinition }> = ({ tool }) => { + const c = useClaudeTokens(); + return ( + + + Developer Info + + + + MCP Config + + + {JSON.stringify(tool.mcp_config, null, 2)} + + + + + Auth type: + {tool.auth_type || 'none'} + + + Status: + {tool.auth_status || 'none'} + + {tool.connected_account_email && ( + + Account: + {tool.connected_account_email} + + )} + + {tool.credentials && Object.keys(tool.credentials).length > 0 && ( + + Credentials: + {Object.keys(tool.credentials).map((key) => ( + + ))} + + )} + + ); +}; + +export default CustomToolDevInfo; diff --git a/frontend/src/app/pages/Tools/McpConfigDialog.tsx b/frontend/src/app/pages/Tools/McpConfigDialog.tsx new file mode 100644 index 00000000..97dadec3 --- /dev/null +++ b/frontend/src/app/pages/Tools/McpConfigDialog.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Avatar from '@mui/material/Avatar'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import { McpServer } from '@/shared/state/mcpRegistrySlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { cleanServerName } from './toolsHelpers'; + +interface McpConfigDialogProps { + open: boolean; + onClose: () => void; + mcpConfigServer: McpServer | null; + mcpConfigJson: string; + setMcpConfigJson: (json: string) => void; + mcpConfigError: string; + setMcpConfigError: (err: string) => void; + mcpAuthType: 'none' | 'env_vars'; + setMcpAuthType: (val: 'none' | 'env_vars') => void; + mcpCredentials: Record; + setMcpCredentials: (creds: Record) => void; + onSave: () => void; +} + +const McpConfigDialog: React.FC = ({ + open, onClose, mcpConfigServer, mcpConfigJson, setMcpConfigJson, + mcpConfigError, setMcpConfigError, mcpAuthType, setMcpAuthType, mcpCredentials, setMcpCredentials, onSave, +}) => { + const c = useClaudeTokens(); + return ( + + + + Configure MCP Tool + + + {mcpConfigServer && ( + + + {mcpConfigServer.iconUrl ? null : (mcpConfigServer.title || cleanServerName(mcpConfigServer.name)).charAt(0).toUpperCase()} + + + + {mcpConfigServer.title || cleanServerName(mcpConfigServer.name)} + + {mcpConfigServer.description} + + + )} + + { setMcpConfigJson(e.target.value); try { JSON.parse(e.target.value); setMcpConfigError(''); } catch { setMcpConfigError('Invalid JSON'); } }} + fullWidth + multiline + minRows={3} + maxRows={8} + error={!!mcpConfigError} + helperText={mcpConfigError || 'Transport config passed to claude_agent_sdk (type, url, command, args, etc.)'} + sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }} + /> + + + Authentication Type + + + + {mcpAuthType !== 'none' && ( + + + Environment Variables + + {Object.entries(mcpCredentials).map(([key, val]) => ( + setMcpCredentials({ ...mcpCredentials, [key]: e.target.value })} + fullWidth + size="small" + type={key.toLowerCase().includes('secret') ? 'password' : 'text'} + sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.elevated, fontFamily: c.font.mono, fontSize: '0.85rem' } }} + /> + ))} + {mcpAuthType === 'env_vars' && ( + + )} + + )} + + + + + + + ); +}; + +export default McpConfigDialog; diff --git a/frontend/src/app/pages/Tools/RegistryBrowserDialog.tsx b/frontend/src/app/pages/Tools/RegistryBrowserDialog.tsx index ad003e95..1d61ca10 100644 --- a/frontend/src/app/pages/Tools/RegistryBrowserDialog.tsx +++ b/frontend/src/app/pages/Tools/RegistryBrowserDialog.tsx @@ -11,27 +11,21 @@ import IconButton from '@mui/material/IconButton'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; import InputAdornment from '@mui/material/InputAdornment'; -import Avatar from '@mui/material/Avatar'; -import EditIcon from '@mui/icons-material/Edit'; import SearchIcon from '@mui/icons-material/Search'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import StorefrontIcon from '@mui/icons-material/Storefront'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import DownloadIcon from '@mui/icons-material/Download'; import StarIcon from '@mui/icons-material/Star'; import SortIcon from '@mui/icons-material/Sort'; import CloudIcon from '@mui/icons-material/Cloud'; import PublicIcon from '@mui/icons-material/Public'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { McpServer, McpServerDetail } from '@/shared/state/mcpRegistrySlice'; import { ToolDefinition } from '@/shared/state/toolsSlice'; import { Skeleton } from '@/app/components/Loading'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { cleanServerName } from './toolsHelpers'; +import RegistryServerRow from './RegistryServerRow'; interface RegistryBrowserDialogProps { open: boolean; @@ -198,174 +192,21 @@ const RegistryBrowserDialog: React.FC = ({ Showing {regServers.length} of {regTotal.toLocaleString()} results - {regServers.map((srv) => { - const isExpanded = expandedServer === srv.name; - const isInstalled = allTools.some((t) => t.name === (srv.title || cleanServerName(srv.name))); - return ( - - { - const next = isExpanded ? null : srv.name; - onExpandServer(srv, next); - }} - sx={{ - display: 'flex', alignItems: 'center', gap: 1.5, - px: 1.5, py: 1, borderRadius: 1.5, cursor: 'pointer', - transition: 'background 0.15s', - '&:hover': { bgcolor: c.bg.secondary }, - ...(isExpanded && { bgcolor: c.bg.secondary }), - }} - > - - {srv.iconUrl ? null : (srv.title || cleanServerName(srv.name)).charAt(0).toUpperCase()} - - - - - {srv.title || cleanServerName(srv.name)} - - {srv.version && } - {srv.remoteType && } - {srv.source === 'google' ? ( - } label="Google" size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.info } }} /> - ) : ( - } label="Community" size="small" sx={{ bgcolor: 'rgba(174,86,48,0.08)', color: c.accent.primary, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.accent.primary } }} /> - )} - {srv.stars != null && ( - - - - {srv.stars >= 1000 ? `${(srv.stars / 1000).toFixed(1)}k` : srv.stars.toLocaleString()} - - - )} - {devMode && !srv.remoteType && ( - - )} - {isInstalled && ( - } label="Installed" size="small" sx={{ bgcolor: `${c.status.success}15`, color: c.status.success, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.success } }} /> - )} - - - {srv.description} - - - - - - - - - {srv.description} - - - - - {srv.name} - - {srv.remoteUrl && ( - - Endpoint - {srv.remoteUrl} - - )} - - {srv.websiteUrl && ( - } - label="Website" - size="small" - sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }} - /> - )} - {srv.repositoryUrl && ( - } - label="Repository" - size="small" - sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }} - /> - )} - - - - {devMode && ( - - {regDetailLoading && expandedServer === srv.name ? ( - - ) : regDetail && regDetail.name === srv.name ? ( - - {(regDetail.keywords?.length > 0 || regDetail.license) && ( - - {regDetail.license && ( - - )} - {regDetail.keywords?.map((kw) => ( - - ))} - - )} - {regDetail.environmentVariables?.length > 0 && ( - - - Required Environment Variables - - {regDetail.environmentVariables.map((ev) => ( - - - {ev.name} - - {ev.description && ( - - {ev.description} - - )} - - ))} - - )} - - ) : null} - - )} - - - - - - - - - ); - })} + {regServers.map((srv) => ( + t.name === (srv.title || cleanServerName(srv.name)))} + devMode={devMode} + regDetail={regDetail} + regDetailLoading={regDetailLoading} + expandedServer={expandedServer} + onExpand={onExpandServer} + onInstall={handleInstall} + onEditInstall={handleEditInstall} + /> + ))} {regServers.length < regTotal && ( diff --git a/frontend/src/app/pages/Tools/RegistryServerRow.tsx b/frontend/src/app/pages/Tools/RegistryServerRow.tsx new file mode 100644 index 00000000..a7f876fa --- /dev/null +++ b/frontend/src/app/pages/Tools/RegistryServerRow.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Collapse from '@mui/material/Collapse'; +import Avatar from '@mui/material/Avatar'; +import EditIcon from '@mui/icons-material/Edit'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import DownloadIcon from '@mui/icons-material/Download'; +import StarIcon from '@mui/icons-material/Star'; +import CloudIcon from '@mui/icons-material/Cloud'; +import PublicIcon from '@mui/icons-material/Public'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { McpServer, McpServerDetail } from '@/shared/state/mcpRegistrySlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { cleanServerName } from './toolsHelpers'; + +interface RegistryServerRowProps { + srv: McpServer; + isExpanded: boolean; + isInstalled: boolean; + devMode: boolean; + regDetail: McpServerDetail | null; + regDetailLoading: boolean; + expandedServer: string | null; + onExpand: (srv: McpServer, next: string | null) => void; + onInstall: (srv: McpServer) => void; + onEditInstall: (srv: McpServer) => void; +} + +const RegistryServerRow: React.FC = ({ + srv, isExpanded, isInstalled, devMode, regDetail, regDetailLoading, expandedServer, + onExpand, onInstall: handleInstall, onEditInstall: handleEditInstall, +}) => { + const c = useClaudeTokens(); + return ( + + { + const next = isExpanded ? null : srv.name; + onExpand(srv, next); + }} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.5, + px: 1.5, py: 1, borderRadius: 1.5, cursor: 'pointer', + transition: 'background 0.15s', + '&:hover': { bgcolor: c.bg.secondary }, + ...(isExpanded && { bgcolor: c.bg.secondary }), + }} + > + + {srv.iconUrl ? null : (srv.title || cleanServerName(srv.name)).charAt(0).toUpperCase()} + + + + + {srv.title || cleanServerName(srv.name)} + + {srv.version && } + {srv.remoteType && } + {srv.source === 'google' ? ( + } label="Google" size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.info } }} /> + ) : ( + } label="Community" size="small" sx={{ bgcolor: 'rgba(174,86,48,0.08)', color: c.accent.primary, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.accent.primary } }} /> + )} + {srv.stars != null && ( + + + + {srv.stars >= 1000 ? `${(srv.stars / 1000).toFixed(1)}k` : srv.stars.toLocaleString()} + + + )} + {devMode && !srv.remoteType && ( + + )} + {isInstalled && ( + } label="Installed" size="small" sx={{ bgcolor: `${c.status.success}15`, color: c.status.success, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.success } }} /> + )} + + + {srv.description} + + + + + + + + + {srv.description} + + + + + {srv.name} + + {srv.remoteUrl && ( + + Endpoint + {srv.remoteUrl} + + )} + + {srv.websiteUrl && ( + } + label="Website" + size="small" + sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }} + /> + )} + {srv.repositoryUrl && ( + } + label="Repository" + size="small" + sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }} + /> + )} + + + + {devMode && ( + + {regDetailLoading && expandedServer === srv.name ? ( + + ) : regDetail && regDetail.name === srv.name ? ( + + {(regDetail.keywords?.length > 0 || regDetail.license) && ( + + {regDetail.license && ( + + )} + {regDetail.keywords?.map((kw) => ( + + ))} + + )} + {regDetail.environmentVariables?.length > 0 && ( + + + Required Environment Variables + + {regDetail.environmentVariables.map((ev) => ( + + + {ev.name} + + {ev.description && ( + + {ev.description} + + )} + + ))} + + )} + + ) : null} + + )} + + + + + + + + + ); +}; + +export default RegistryServerRow; diff --git a/frontend/src/app/pages/Tools/ServiceGroup.tsx b/frontend/src/app/pages/Tools/ServiceGroup.tsx new file mode 100644 index 00000000..9d35499a --- /dev/null +++ b/frontend/src/app/pages/Tools/ServiceGroup.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; +import EditIcon from '@mui/icons-material/Edit'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import BlockIcon from '@mui/icons-material/Block'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import PanToolIcon from '@mui/icons-material/PanTool'; +import { ToolDefinition } from '@/shared/state/toolsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { Integration } from './integrations'; + +const toDisplayName = (name: string, serviceName?: string) => { + let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + if (serviceName) { + const svcLower = serviceName.toLowerCase(); + const variants = [svcLower, svcLower.replace(/s$/, '')]; + for (const v of variants) { + display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim(); + } + display = display.replace(/\s{2,}/g, ' ').trim(); + } + return display; +}; + +const firstSentence = (desc: string) => { + if (!desc) return ''; + const match = desc.match(/^(.+?(?:\.|$))/); + return match ? match[1].trim() : desc.substring(0, 100); +}; + +interface ServiceGroupProps { + tool: ToolDefinition; + ig: Integration | undefined; + serviceName: string; + data: { read?: string[]; write?: string[] }; + isFirstGroup?: boolean; + perms: Record; + descriptions: Record; + schemas: Record; + expandedServices: Record; + setExpandedServices: React.Dispatch>>; + expandedSchema: string | null; + setExpandedSchema: React.Dispatch>; + devMode: boolean; + onGroupPermissionChange: (toolId: string, names: string[], policy: string) => void; + onPermissionChange: (toolId: string, toolName: string, policy: string) => void; +} + +const ServiceGroup: React.FC = ({ + tool, ig, serviceName, data, isFirstGroup, + perms, descriptions, schemas, + expandedServices, setExpandedServices, expandedSchema, setExpandedSchema, devMode, + onGroupPermissionChange: handleGroupPermissionChange, + onPermissionChange: handlePermissionChange, +}) => { + const c = useClaudeTokens(); + + const getGroupPolicy = (names: string[]) => { + if (names.length === 0) return 'ask'; + const policies = names.map((n) => perms[n] || 'ask'); + if (policies.every((p) => p === 'always_allow')) return 'always_allow'; + if (policies.every((p) => p === 'deny')) return 'deny'; + if (policies.every((p) => p === 'ask')) return 'ask'; + return 'mixed'; + }; + + const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => ( + e.stopPropagation()}> + onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}> + onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}> + onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}> + + ); + + const renderActionList = (names: string[]) => names.map((name) => { + const schemaKey = `${tool.id}:${name}`; + const schema = schemas[name]; + const schemaProps = schema?.properties as Record | undefined; + const schemaRequired = (schema?.required || []) as string[]; + return ( + + devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}> + + {toDisplayName(name, serviceName)} + {descriptions[name] && {firstSentence(descriptions[name])}} + + handlePermissionChange(tool.id, name, v)} size={14} /> + + {devMode && expandedSchema === schemaKey && schemaProps && ( + + Input Parameters + {Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => ( + + {pName} + {pDef?.type || 'any'} + {schemaRequired.includes(pName) && } + {pDef?.description && {pDef.description}} + + ))} + + )} + + ); + }); + + const svcKey = `${tool.id}:${serviceName}`; + const isOpen = expandedServices[svcKey] ?? false; + const allNames = [...(data.read || []), ...(data.write || [])]; + const svcPolicy = getGroupPolicy(allNames); + const count = allNames.length; + const isReddit = + ig?.id === 'reddit' || + tool.name?.toLowerCase() === 'reddit' || + (tool.command || '').toLowerCase().includes('reddit'); + const isYoutube = + ig?.id === 'youtube' || + tool.name?.toLowerCase() === 'youtube' || + (tool.command || '').toLowerCase().includes('youtube'); + const isSubredditsForReddit = + isReddit && /subreddit/i.test(serviceName); + // YouTube marker lands on the first service group since YouTube has no drill-down. + const showPermissionMarker = + isSubredditsForReddit || (isYoutube && isFirstGroup); + + return ( + + setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))} + > + + + {serviceName} + + + + handleGroupPermissionChange(tool.id, allNames, v)} /> + + + + + {(data.read?.length || 0) > 0 && ( + + + + + Read-only + + + handleGroupPermissionChange(tool.id, data.read!, v)} size={14} /> + + {renderActionList(data.read!)} + + )} + {(data.write?.length || 0) > 0 && ( + + + + + Write / delete + + + handleGroupPermissionChange(tool.id, data.write!, v)} size={14} /> + + {renderActionList(data.write!)} + + )} + + + + ); +}; + +export default ServiceGroup; diff --git a/frontend/src/app/pages/Tools/ToolDialogs.tsx b/frontend/src/app/pages/Tools/ToolDialogs.tsx index c6b0de7f..f2f86c2b 100644 --- a/frontend/src/app/pages/Tools/ToolDialogs.tsx +++ b/frontend/src/app/pages/Tools/ToolDialogs.tsx @@ -7,19 +7,14 @@ import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import TextField from '@mui/material/TextField'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; import CircularProgress from '@mui/material/CircularProgress'; -import Avatar from '@mui/material/Avatar'; -import ExtensionIcon from '@mui/icons-material/Extension'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import LinkIcon from '@mui/icons-material/Link'; import { McpServer } from '@/shared/state/mcpRegistrySlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { Integration } from './integrations'; -import { ToolForm, cleanServerName } from './toolsHelpers'; +import { ToolForm } from './toolsHelpers'; +import McpConfigDialog from './McpConfigDialog'; interface ToolDialogsProps { dialogOpen: boolean; @@ -83,107 +78,20 @@ const ToolDialogs: React.FC = ({ - setMcpConfigOpen(false)} - maxWidth="sm" - fullWidth - PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` } }} - > - - - Configure MCP Tool - - - {mcpConfigServer && ( - - - {mcpConfigServer.iconUrl ? null : (mcpConfigServer.title || cleanServerName(mcpConfigServer.name)).charAt(0).toUpperCase()} - - - - {mcpConfigServer.title || cleanServerName(mcpConfigServer.name)} - - {mcpConfigServer.description} - - - )} - - { setMcpConfigJson(e.target.value); try { JSON.parse(e.target.value); setMcpConfigError(''); } catch { setMcpConfigError('Invalid JSON'); } }} - fullWidth - multiline - minRows={3} - maxRows={8} - error={!!mcpConfigError} - helperText={mcpConfigError || 'Transport config passed to claude_agent_sdk (type, url, command, args, etc.)'} - sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }} - /> - - - Authentication Type - - - - {mcpAuthType !== 'none' && ( - - - Environment Variables - - {Object.entries(mcpCredentials).map(([key, val]) => ( - setMcpCredentials({ ...mcpCredentials, [key]: e.target.value })} - fullWidth - size="small" - type={key.toLowerCase().includes('secret') ? 'password' : 'text'} - sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.elevated, fontFamily: c.font.mono, fontSize: '0.85rem' } }} - /> - ))} - {mcpAuthType === 'env_vars' && ( - - )} - - )} - - - - - - + mcpConfigServer={mcpConfigServer} + mcpConfigJson={mcpConfigJson} + setMcpConfigJson={setMcpConfigJson} + mcpConfigError={mcpConfigError} + setMcpConfigError={setMcpConfigError} + mcpAuthType={mcpAuthType} + setMcpAuthType={setMcpAuthType} + mcpCredentials={mcpCredentials} + setMcpCredentials={setMcpCredentials} + onSave={handleMcpConfigSave} + /> void; + setEditingId: (id: string | null) => void; + setForm: (f: ToolForm) => void; + setDialogOpen: (v: boolean) => void; + closeMenu: () => void; +} + +export function useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, setForm, setDialogOpen, closeMenu }: Deps) { + const dispatch = useAppDispatch(); + + const [registryOpen, setRegistryOpen] = useState(false); + const [regQuery, setRegQuery] = useState(''); + const [regSort, setRegSort] = useState<'name' | 'stars'>('stars'); + // Default 'curated' hides the long tail; client-side filter, backend still returns the full list. + const [regSource, setRegSource] = useState('curated'); + const [expandedServer, setExpandedServer] = useState(null); + const debounceRef = useRef | null>(null); + + const [mcpConfigOpen, setMcpConfigOpen] = useState(false); + const [mcpConfigServer, setMcpConfigServer] = useState(null); + const [mcpAuthType, setMcpAuthType] = useState<'none' | 'env_vars'>('none'); + const [mcpCredentials, setMcpCredentials] = useState>({}); + const [mcpConfigJson, setMcpConfigJson] = useState(''); + const [mcpConfigError, setMcpConfigError] = useState(''); + + // Translate UI "curated" pseudo-source to "" for the backend; the whitelist is applied client-side. + const _backendSource = (s: RegSource): '' | 'community' | 'google' => + s === 'curated' ? '' : s; + + const openRegistryBrowser = () => { + closeMenu(); + setRegistryOpen(true); + setRegQuery(''); + setRegSort('stars'); + setRegSource(''); + setExpandedServer(null); + dispatch(fetchRegistryStats()); + dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' })); + }; + + const handleRegSearch = useCallback((q: string, sort?: 'name' | 'stars', source?: RegSource) => { + setRegQuery(q); + setExpandedServer(null); + const sortVal = sort ?? regSort; + const sourceVal = source ?? regSource; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + dispatch(searchRegistry({ q, limit: 20, offset: 0, sort: sortVal, source: _backendSource(sourceVal) })); + }, 300); + }, [dispatch, regSort, regSource]); + + const handleLoadMore = () => { + dispatch(searchRegistry({ q: regQuery, limit: 20, offset: regServersRaw.length, sort: regSort, source: _backendSource(regSource) })); + }; + + const handleRegSort = (sort: 'name' | 'stars') => { + setRegSort(sort); + setExpandedServer(null); + dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort, source: _backendSource(regSource) })); + }; + + const handleRegSourceFilter = (_: React.MouseEvent, val: RegSource) => { + if (val === null) return; + setRegSource(val); + setExpandedServer(null); + dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort: regSort, source: _backendSource(val) })); + }; + + const openMcpConfigDialog = (srv: McpServer) => { + setMcpConfigServer(srv); + setMcpAuthType('none'); + setMcpCredentials({}); + const derivedConfig = serverToMcpConfig(srv); + setMcpConfigJson(JSON.stringify( + Object.keys(derivedConfig).length > 0 ? derivedConfig : {}, + null, 2, + )); + setMcpConfigError(''); + setMcpConfigOpen(true); + }; + + const handleMcpConfigSave = async () => { + if (!mcpConfigServer) return; + let parsedConfig: Record = {}; + try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; } + + const f = serverToToolForm(mcpConfigServer); + const authStatus = 'configured'; + + await dispatch(createTool({ + name: f.name, + description: f.description, + command: '', + mcp_config: parsedConfig, + credentials: mcpCredentials, + auth_type: mcpAuthType, + auth_status: authStatus, + })); + + setMcpConfigOpen(false); + setSnackbar({ open: true, message: `Installed "${f.name}" as MCP tool` }); + }; + + const handleInstall = async (srv: McpServer) => { + const f = serverToToolForm(srv); + const mcpConfig = serverToMcpConfig(srv); + const hasConfig = Object.keys(mcpConfig).length > 0; + + if (srv.source === 'google' && srv.remoteUrl && hasConfig) { + await dispatch(createTool({ + name: f.name, + description: f.description, + command: '', + mcp_config: mcpConfig, + credentials: {}, + auth_type: 'oauth2', + auth_status: 'configured', + })); + setSnackbar({ open: true, message: `Installed "${f.name}", click "Connect Google" to authorize` }); + } else if (hasConfig && mcpConfig.type === 'stdio') { + const result = await dispatch(createTool({ + name: f.name, + description: f.description, + command: '', + mcp_config: mcpConfig, + credentials: {}, + auth_type: 'none', + auth_status: 'configured', + })); + if (createTool.fulfilled.match(result)) { + const newTool = result.payload; + setSnackbar({ open: true, message: `Installed "${f.name}", discovering actions…` }); + const discoverResult = await dispatch(discoverTools(newTool.id)); + if (discoverTools.fulfilled.match(discoverResult)) { + setSnackbar({ open: true, message: `${f.name} ready, actions discovered` }); + } else { + const detail = (discoverResult as any).error?.message + || 'discovery failed; the MCP server may need setup first'; + setSnackbar({ open: true, message: `${f.name}: ${detail}`, severity: 'error' }); + } + } + } else { + openMcpConfigDialog(srv); + } + }; + + const handleEditInstall = (srv: McpServer) => { + setRegistryOpen(false); + const f = serverToToolForm(srv); + setEditingId(null); + setForm(f); + setDialogOpen(true); + }; + + return { + registryOpen, setRegistryOpen, regQuery, regSort, regSource, expandedServer, setExpandedServer, + mcpConfigOpen, setMcpConfigOpen, mcpConfigServer, mcpAuthType, setMcpAuthType, mcpCredentials, setMcpCredentials, + mcpConfigJson, setMcpConfigJson, mcpConfigError, setMcpConfigError, + openRegistryBrowser, handleRegSearch, handleLoadMore, handleRegSort, handleRegSourceFilter, + handleMcpConfigSave, handleInstall, handleEditInstall, + }; +} diff --git a/frontend/src/app/pages/Tools/useToolConnections.ts b/frontend/src/app/pages/Tools/useToolConnections.ts new file mode 100644 index 00000000..5687e0bf --- /dev/null +++ b/frontend/src/app/pages/Tools/useToolConnections.ts @@ -0,0 +1,223 @@ +import { useState } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { + updateTool, + fetchToolStatus, + discoverTools, + startOAuth, + startDeviceCodeLogin, + pollDeviceCodeStatus, + disconnectM365, + ToolDefinition, +} from '@/shared/state/toolsSlice'; +import { API_BASE } from '@/shared/config'; +import { Integration } from './integrations'; + +type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' }; + +interface Deps { + items: Record; + setSnackbar: (s: Snackbar) => void; + setExpandedToolId: (id: string | null) => void; +} + +export function useToolConnections({ items, setSnackbar, setExpandedToolId }: Deps) { + const dispatch = useAppDispatch(); + + const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false); + const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState(null); + const [deviceCode, setDeviceCode] = useState(''); + const [deviceCodeUrl, setDeviceCodeUrl] = useState(''); + const [deviceCodeStatus, setDeviceCodeStatus] = useState<'loading' | 'awaiting' | 'connected' | 'error'>('loading'); + + const [credDialogOpen, setCredDialogOpen] = useState(false); + const [credDialogToolId, setCredDialogToolId] = useState(null); + const [credDialogIntegration, setCredDialogIntegration] = useState(null); + const [credDialogValues, setCredDialogValues] = useState>({}); + const [credDialogSaving, setCredDialogSaving] = useState(false); + + const handleOAuthConnect = async (toolId: string) => { + const result = await dispatch(startOAuth(toolId)); + if (startOAuth.fulfilled.match(result)) { + const { auth_url } = result.payload; + const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100'); + + const afterConnect = async () => { + const statusResult = await dispatch(fetchToolStatus(toolId)); + if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') { + setSnackbar({ open: true, message: 'Account connected! Discovering actions…' }); + setExpandedToolId(toolId); + dispatch(discoverTools(toolId)); + } else { + setSnackbar({ open: true, message: 'Account connected!' }); + } + }; + + const onMessage = (event: MessageEvent) => { + if (event.data?.type === 'oauth_complete' && event.data?.tool_id === toolId) { + afterConnect(); + window.removeEventListener('message', onMessage); + } + }; + window.addEventListener('message', onMessage); + + const pollInterval = setInterval(() => { + if (popup?.closed) { + clearInterval(pollInterval); + afterConnect(); + window.removeEventListener('message', onMessage); + } + }, 1000); + } else { + setSnackbar({ open: true, message: 'OAuth failed; check that OAuth credentials are set in backend .env', severity: 'error' }); + } + }; + + const handleDeviceCodeConnect = async (toolId: string) => { + setDeviceCodeDialogToolId(toolId); + setDeviceCodeStatus('loading'); + setDeviceCode(''); + setDeviceCodeUrl(''); + setDeviceCodeDialogOpen(true); + + const result = await dispatch(startDeviceCodeLogin(toolId)); + if (startDeviceCodeLogin.fulfilled.match(result)) { + const { device_code, device_code_url } = result.payload; + setDeviceCode(device_code); + const url = device_code_url || 'https://login.microsoft.com/device'; + setDeviceCodeUrl(url); + setDeviceCodeStatus('awaiting'); + + window.open(url, 'm365-login', 'width=500,height=700,left=200,top=100'); + + const poll = setInterval(async () => { + const statusResult = await dispatch(pollDeviceCodeStatus(toolId)); + if (pollDeviceCodeStatus.fulfilled.match(statusResult)) { + const { status, email } = statusResult.payload; + if (status === 'connected') { + clearInterval(poll); + setDeviceCodeStatus('connected'); + setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering actions…` }); + setDeviceCodeDialogOpen(false); + setExpandedToolId(toolId); + await dispatch(fetchToolStatus(toolId)); + dispatch(discoverTools(toolId)); + } else if (status === 'error') { + clearInterval(poll); + setDeviceCodeStatus('error'); + } + } + }, 2000); + + setTimeout(() => clearInterval(poll), 300000); + } else { + setDeviceCodeStatus('error'); + } + }; + + const handleM365Disconnect = async (toolId: string) => { + await dispatch(disconnectM365(toolId)); + setSnackbar({ open: true, message: 'Disconnected from Microsoft 365' }); + }; + + const openCredentialsDialog = (toolId: string, integration: Integration) => { + const tool = items[toolId]; + const existing = tool?.credentials || {}; + const initial: Record = {}; + for (const field of integration.credentialFields || []) { + initial[field.key] = existing[field.key] || ''; + } + setCredDialogToolId(toolId); + setCredDialogIntegration(integration); + setCredDialogValues(initial); + setCredDialogOpen(true); + }; + + const handleCredentialsSave = async () => { + if (!credDialogToolId || !credDialogIntegration) return; + const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim()); + if (hasEmpty) return; + + setCredDialogSaving(true); + try { + const result = await dispatch(updateTool({ + id: credDialogToolId, + credentials: credDialogValues, + auth_type: 'env_vars', + auth_status: 'connected', + })); + if (updateTool.fulfilled.match(result)) { + setCredDialogOpen(false); + setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering actions…` }); + dispatch(discoverTools(credDialogToolId)); + } else { + setSnackbar({ open: true, message: 'Failed to save credentials', severity: 'error' }); + } + } finally { + setCredDialogSaving(false); + } + }; + + const handleSlackAutoConnect = async () => { + if (!credDialogToolId || !credDialogIntegration) return; + const slackBridge = (window as any).openswarm?.connectSlack; + if (!slackBridge) { + setSnackbar({ open: true, message: 'Slack auto-connect requires the desktop app', severity: 'error' }); + return; + } + setCredDialogSaving(true); + try { + const { token, cookie } = await slackBridge(); + const creds = { SLACK_MCP_XOXC_TOKEN: token, SLACK_MCP_XOXD_TOKEN: cookie }; + const result = await dispatch(updateTool({ + id: credDialogToolId, + credentials: creds, + auth_type: 'env_vars', + auth_status: 'connected', + })); + if (updateTool.fulfilled.match(result)) { + setCredDialogOpen(false); + setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' }); + dispatch(discoverTools(credDialogToolId)); + } else { + setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' }); + } + } catch (err: any) { + setSnackbar({ open: true, message: err?.message || 'Slack sign-in cancelled', severity: 'error' }); + } finally { + setCredDialogSaving(false); + } + }; + + const handleDisconnectIntegration = async (toolId: string, integration: Integration) => { + if (integration.authType === 'oauth2') { + fetch(`${API_BASE}/tools/${toolId}/oauth/disconnect`, { method: 'POST' }).catch(() => {}); + const result = await dispatch(updateTool({ + id: toolId, + oauth_tokens: {}, + auth_status: 'configured', + connected_account_email: '', + })); + if (updateTool.fulfilled.match(result)) { + setSnackbar({ open: true, message: `${integration.name} disconnected. You can now connect a different account.` }); + } else { + setSnackbar({ open: true, message: `Failed to disconnect ${integration.name}`, severity: 'error' }); + } + } else { + await dispatch(updateTool({ + id: toolId, + credentials: {}, + auth_type: 'none', + auth_status: 'configured', + })); + setSnackbar({ open: true, message: `${integration.name} disconnected` }); + } + }; + + return { + deviceCodeDialogOpen, setDeviceCodeDialogOpen, deviceCode, deviceCodeUrl, deviceCodeStatus, + credDialogOpen, setCredDialogOpen, credDialogIntegration, credDialogValues, setCredDialogValues, credDialogSaving, + handleOAuthConnect, handleDeviceCodeConnect, handleM365Disconnect, + openCredentialsDialog, handleCredentialsSave, handleSlackAutoConnect, handleDisconnectIntegration, + }; +} diff --git a/frontend/src/app/pages/Tools/useToolsActions.ts b/frontend/src/app/pages/Tools/useToolsActions.ts index 58f9ef2a..39fb142c 100644 --- a/frontend/src/app/pages/Tools/useToolsActions.ts +++ b/frontend/src/app/pages/Tools/useToolsActions.ts @@ -1,30 +1,21 @@ -import React, { useCallback, useRef, useState } from 'react'; +import { useState } from 'react'; import { useAppDispatch } from '@/shared/hooks'; import { createTool, updateTool, deleteTool, - startOAuth, - fetchToolStatus, discoverTools, - startDeviceCodeLogin, - pollDeviceCodeStatus, - disconnectM365, updateBuiltinPermissions, ToolDefinition, BuiltinTool, } from '@/shared/state/toolsSlice'; -import { - searchRegistry, - fetchRegistryStats, - McpServer, -} from '@/shared/state/mcpRegistrySlice'; -import { API_BASE } from '@/shared/config'; +import { McpServer } from '@/shared/state/mcpRegistrySlice'; +import { ToolForm, emptyForm } from './toolsHelpers'; import { Integration } from './integrations'; -import { ToolForm, emptyForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers'; +import { useToolConnections } from './useToolConnections'; +import { useRegistryBrowser } from './useRegistryBrowser'; type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' }; -type RegSource = '' | 'community' | 'google' | 'curated'; interface ToolsActionsDeps { items: Record; @@ -40,21 +31,7 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); - const [registryOpen, setRegistryOpen] = useState(false); - const [regQuery, setRegQuery] = useState(''); - const [regSort, setRegSort] = useState<'name' | 'stars'>('stars'); - // Default 'curated' hides the long tail; client-side filter, backend still returns the full list. - const [regSource, setRegSource] = useState('curated'); - const [expandedServer, setExpandedServer] = useState(null); const [snackbar, setSnackbar] = useState({ open: false, message: '' }); - const debounceRef = useRef | null>(null); - - const [mcpConfigOpen, setMcpConfigOpen] = useState(false); - const [mcpConfigServer, setMcpConfigServer] = useState(null); - const [mcpAuthType, setMcpAuthType] = useState<'none' | 'env_vars'>('none'); - const [mcpCredentials, setMcpCredentials] = useState>({}); - const [mcpConfigJson, setMcpConfigJson] = useState(''); - const [mcpConfigError, setMcpConfigError] = useState(''); const [expandedToolId, setExpandedToolId] = useState(null); const [discovering, setDiscovering] = useState(false); @@ -62,17 +39,8 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T const [expandedServices, setExpandedServices] = useState>({}); const [expandedSchema, setExpandedSchema] = useState(null); - const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false); - const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState(null); - const [deviceCode, setDeviceCode] = useState(''); - const [deviceCodeUrl, setDeviceCodeUrl] = useState(''); - const [deviceCodeStatus, setDeviceCodeStatus] = useState<'loading' | 'awaiting' | 'connected' | 'error'>('loading'); - - const [credDialogOpen, setCredDialogOpen] = useState(false); - const [credDialogToolId, setCredDialogToolId] = useState(null); - const [credDialogIntegration, setCredDialogIntegration] = useState(null); - const [credDialogValues, setCredDialogValues] = useState>({}); - const [credDialogSaving, setCredDialogSaving] = useState(false); + const connections = useToolConnections({ items, setSnackbar, setExpandedToolId }); + const registry = useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, setForm, setDialogOpen, closeMenu }); const getInstalledIntegration = (integration: Integration): ToolDefinition | undefined => { return allTools.find((t) => t.name === integration.name); @@ -203,17 +171,6 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T setDialogOpen(true); }; - const openRegistryBrowser = () => { - closeMenu(); - setRegistryOpen(true); - setRegQuery(''); - setRegSort('stars'); - setRegSource(''); - setExpandedServer(null); - dispatch(fetchRegistryStats()); - dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' })); - }; - const openEdit = (tool: ToolDefinition) => { setEditingId(tool.id); setForm({ name: tool.name, description: tool.description, command: tool.command }); @@ -228,316 +185,14 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T const handleDelete = async (id: string) => { await dispatch(deleteTool(id)); }; - // Translate UI "curated" pseudo-source to "" for the backend; the whitelist is applied client-side. - const _backendSource = (s: RegSource): '' | 'community' | 'google' => - s === 'curated' ? '' : s; - - const handleRegSearch = useCallback((q: string, sort?: 'name' | 'stars', source?: RegSource) => { - setRegQuery(q); - setExpandedServer(null); - const sortVal = sort ?? regSort; - const sourceVal = source ?? regSource; - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - dispatch(searchRegistry({ q, limit: 20, offset: 0, sort: sortVal, source: _backendSource(sourceVal) })); - }, 300); - }, [dispatch, regSort, regSource]); - - const handleLoadMore = () => { - dispatch(searchRegistry({ q: regQuery, limit: 20, offset: regServersRaw.length, sort: regSort, source: _backendSource(regSource) })); - }; - - const handleRegSort = (sort: 'name' | 'stars') => { - setRegSort(sort); - setExpandedServer(null); - dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort, source: _backendSource(regSource) })); - }; - - const handleRegSourceFilter = (_: React.MouseEvent, val: RegSource) => { - if (val === null) return; - setRegSource(val); - setExpandedServer(null); - dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort: regSort, source: _backendSource(val) })); - }; - - const openMcpConfigDialog = (srv: McpServer) => { - setMcpConfigServer(srv); - setMcpAuthType('none'); - setMcpCredentials({}); - const derivedConfig = serverToMcpConfig(srv); - setMcpConfigJson(JSON.stringify( - Object.keys(derivedConfig).length > 0 ? derivedConfig : {}, - null, 2, - )); - setMcpConfigError(''); - setMcpConfigOpen(true); - }; - - const handleMcpConfigSave = async () => { - if (!mcpConfigServer) return; - let parsedConfig: Record = {}; - try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; } - - const f = serverToToolForm(mcpConfigServer); - const authStatus = 'configured'; - - await dispatch(createTool({ - name: f.name, - description: f.description, - command: '', - mcp_config: parsedConfig, - credentials: mcpCredentials, - auth_type: mcpAuthType, - auth_status: authStatus, - })); - - setMcpConfigOpen(false); - setSnackbar({ open: true, message: `Installed "${f.name}" as MCP tool` }); - }; - - const handleInstall = async (srv: McpServer) => { - const f = serverToToolForm(srv); - const mcpConfig = serverToMcpConfig(srv); - const hasConfig = Object.keys(mcpConfig).length > 0; - - if (srv.source === 'google' && srv.remoteUrl && hasConfig) { - await dispatch(createTool({ - name: f.name, - description: f.description, - command: '', - mcp_config: mcpConfig, - credentials: {}, - auth_type: 'oauth2', - auth_status: 'configured', - })); - setSnackbar({ open: true, message: `Installed "${f.name}", click "Connect Google" to authorize` }); - } else if (hasConfig && mcpConfig.type === 'stdio') { - const result = await dispatch(createTool({ - name: f.name, - description: f.description, - command: '', - mcp_config: mcpConfig, - credentials: {}, - auth_type: 'none', - auth_status: 'configured', - })); - if (createTool.fulfilled.match(result)) { - const newTool = result.payload; - setSnackbar({ open: true, message: `Installed "${f.name}", discovering actions…` }); - const discoverResult = await dispatch(discoverTools(newTool.id)); - if (discoverTools.fulfilled.match(discoverResult)) { - setSnackbar({ open: true, message: `${f.name} ready, actions discovered` }); - } else { - const detail = (discoverResult as any).error?.message - || 'discovery failed; the MCP server may need setup first'; - setSnackbar({ open: true, message: `${f.name}: ${detail}`, severity: 'error' }); - } - } - } else { - openMcpConfigDialog(srv); - } - }; - - const handleEditInstall = (srv: McpServer) => { - setRegistryOpen(false); - const f = serverToToolForm(srv); - setEditingId(null); - setForm(f); - setDialogOpen(true); - }; - - const handleOAuthConnect = async (toolId: string) => { - const result = await dispatch(startOAuth(toolId)); - if (startOAuth.fulfilled.match(result)) { - const { auth_url } = result.payload; - const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100'); - - const afterConnect = async () => { - const statusResult = await dispatch(fetchToolStatus(toolId)); - if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') { - setSnackbar({ open: true, message: 'Account connected! Discovering actions…' }); - setExpandedToolId(toolId); - dispatch(discoverTools(toolId)); - } else { - setSnackbar({ open: true, message: 'Account connected!' }); - } - }; - - const onMessage = (event: MessageEvent) => { - if (event.data?.type === 'oauth_complete' && event.data?.tool_id === toolId) { - afterConnect(); - window.removeEventListener('message', onMessage); - } - }; - window.addEventListener('message', onMessage); - - const pollInterval = setInterval(() => { - if (popup?.closed) { - clearInterval(pollInterval); - afterConnect(); - window.removeEventListener('message', onMessage); - } - }, 1000); - } else { - setSnackbar({ open: true, message: 'OAuth failed; check that OAuth credentials are set in backend .env', severity: 'error' }); - } - }; - - const handleDeviceCodeConnect = async (toolId: string) => { - setDeviceCodeDialogToolId(toolId); - setDeviceCodeStatus('loading'); - setDeviceCode(''); - setDeviceCodeUrl(''); - setDeviceCodeDialogOpen(true); - - const result = await dispatch(startDeviceCodeLogin(toolId)); - if (startDeviceCodeLogin.fulfilled.match(result)) { - const { device_code, device_code_url } = result.payload; - setDeviceCode(device_code); - const url = device_code_url || 'https://login.microsoft.com/device'; - setDeviceCodeUrl(url); - setDeviceCodeStatus('awaiting'); - - window.open(url, 'm365-login', 'width=500,height=700,left=200,top=100'); - - const poll = setInterval(async () => { - const statusResult = await dispatch(pollDeviceCodeStatus(toolId)); - if (pollDeviceCodeStatus.fulfilled.match(statusResult)) { - const { status, email } = statusResult.payload; - if (status === 'connected') { - clearInterval(poll); - setDeviceCodeStatus('connected'); - setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering actions…` }); - setDeviceCodeDialogOpen(false); - setExpandedToolId(toolId); - await dispatch(fetchToolStatus(toolId)); - dispatch(discoverTools(toolId)); - } else if (status === 'error') { - clearInterval(poll); - setDeviceCodeStatus('error'); - } - } - }, 2000); - - setTimeout(() => clearInterval(poll), 300000); - } else { - setDeviceCodeStatus('error'); - } - }; - - const handleM365Disconnect = async (toolId: string) => { - await dispatch(disconnectM365(toolId)); - setSnackbar({ open: true, message: 'Disconnected from Microsoft 365' }); - }; - - const openCredentialsDialog = (toolId: string, integration: Integration) => { - const tool = items[toolId]; - const existing = tool?.credentials || {}; - const initial: Record = {}; - for (const field of integration.credentialFields || []) { - initial[field.key] = existing[field.key] || ''; - } - setCredDialogToolId(toolId); - setCredDialogIntegration(integration); - setCredDialogValues(initial); - setCredDialogOpen(true); - }; - - const handleCredentialsSave = async () => { - if (!credDialogToolId || !credDialogIntegration) return; - const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim()); - if (hasEmpty) return; - - setCredDialogSaving(true); - try { - const result = await dispatch(updateTool({ - id: credDialogToolId, - credentials: credDialogValues, - auth_type: 'env_vars', - auth_status: 'connected', - })); - if (updateTool.fulfilled.match(result)) { - setCredDialogOpen(false); - setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering actions…` }); - dispatch(discoverTools(credDialogToolId)); - } else { - setSnackbar({ open: true, message: 'Failed to save credentials', severity: 'error' }); - } - } finally { - setCredDialogSaving(false); - } - }; - - const handleSlackAutoConnect = async () => { - if (!credDialogToolId || !credDialogIntegration) return; - const slackBridge = (window as any).openswarm?.connectSlack; - if (!slackBridge) { - setSnackbar({ open: true, message: 'Slack auto-connect requires the desktop app', severity: 'error' }); - return; - } - setCredDialogSaving(true); - try { - const { token, cookie } = await slackBridge(); - const creds = { SLACK_MCP_XOXC_TOKEN: token, SLACK_MCP_XOXD_TOKEN: cookie }; - const result = await dispatch(updateTool({ - id: credDialogToolId, - credentials: creds, - auth_type: 'env_vars', - auth_status: 'connected', - })); - if (updateTool.fulfilled.match(result)) { - setCredDialogOpen(false); - setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' }); - dispatch(discoverTools(credDialogToolId)); - } else { - setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' }); - } - } catch (err: any) { - setSnackbar({ open: true, message: err?.message || 'Slack sign-in cancelled', severity: 'error' }); - } finally { - setCredDialogSaving(false); - } - }; - - const handleDisconnectIntegration = async (toolId: string, integration: Integration) => { - if (integration.authType === 'oauth2') { - fetch(`${API_BASE}/tools/${toolId}/oauth/disconnect`, { method: 'POST' }).catch(() => {}); - const result = await dispatch(updateTool({ - id: toolId, - oauth_tokens: {}, - auth_status: 'configured', - connected_account_email: '', - })); - if (updateTool.fulfilled.match(result)) { - setSnackbar({ open: true, message: `${integration.name} disconnected. You can now connect a different account.` }); - } else { - setSnackbar({ open: true, message: `Failed to disconnect ${integration.name}`, severity: 'error' }); - } - } else { - await dispatch(updateTool({ - id: toolId, - credentials: {}, - auth_type: 'none', - auth_status: 'configured', - })); - setSnackbar({ open: true, message: `${integration.name} disconnected` }); - } - }; - return { - dialogOpen, setDialogOpen, editingId, setEditingId, form, setForm, - registryOpen, setRegistryOpen, regQuery, regSort, regSource, expandedServer, setExpandedServer, snackbar, setSnackbar, - mcpConfigOpen, setMcpConfigOpen, mcpConfigServer, mcpAuthType, setMcpAuthType, mcpCredentials, setMcpCredentials, - mcpConfigJson, setMcpConfigJson, mcpConfigError, setMcpConfigError, + dialogOpen, setDialogOpen, editingId, setEditingId, form, setForm, snackbar, setSnackbar, expandedToolId, setExpandedToolId, discovering, integrationLoading, expandedServices, setExpandedServices, expandedSchema, setExpandedSchema, - deviceCodeDialogOpen, setDeviceCodeDialogOpen, deviceCode, deviceCodeUrl, deviceCodeStatus, - credDialogOpen, setCredDialogOpen, credDialogIntegration, credDialogValues, setCredDialogValues, credDialogSaving, + ...connections, + ...registry, handleIntegrationToggle, handleDiscover, handlePermissionChange, handleGroupPermissionChange, handleBulkReadOnly, handleResetPermissions, handleBuiltinPermissionChange, handleBuiltinCategoryPermissionChange, - handleSectionEnabledChange, openCreate, openRegistryBrowser, openEdit, handleSave, handleDelete, - handleRegSearch, handleLoadMore, handleRegSort, handleRegSourceFilter, handleMcpConfigSave, handleInstall, - handleEditInstall, handleOAuthConnect, handleDeviceCodeConnect, handleM365Disconnect, openCredentialsDialog, - handleCredentialsSave, handleSlackAutoConnect, handleDisconnectIntegration, + handleSectionEnabledChange, openCreate, openEdit, handleSave, handleDelete, }; }