diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index d9398776..4590687f 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; @@ -20,38 +20,25 @@ import { fetchTools, fetchBuiltinTools, fetchBuiltinPermissions, - updateBuiltinPermissions, - createTool, - updateTool, - deleteTool, - startOAuth, - fetchToolStatus, - discoverTools, - startDeviceCodeLogin, - pollDeviceCodeStatus, - disconnectM365, ToolDefinition, - BuiltinTool, } from '@/shared/state/toolsSlice'; import { - searchRegistry, - fetchRegistryStats, fetchServerDetail, clearDetail, - McpServer, } from '@/shared/state/mcpRegistrySlice'; import { Skeleton } from '@/app/components/Loading'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { API_BASE } from '@/shared/config'; import { Integration, INTEGRATIONS } from './integrations'; -import { CATEGORY_ORDER, ToolForm, emptyForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers'; +import { CATEGORY_ORDER } from './toolsHelpers'; import ToolSection from './ToolSection'; import BrowserPermissionCard from './BrowserPermissionCard'; import RegistryBrowserDialog from './RegistryBrowserDialog'; import ToolDialogs from './ToolDialogs'; import CustomToolCard from './CustomToolCard'; import IntegrationGalleryCard from './IntegrationGalleryCard'; +import { useToolsActions } from './useToolsActions'; +import { useBuiltinSections } from './useBuiltinSections'; const Tools: React.FC = () => { const c = useClaudeTokens(); @@ -64,10 +51,6 @@ const Tools: React.FC = () => { const uninstalledIntegrations = useMemo(() => INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name)), [allTools]); const getIntegrationForTool = useCallback((tool: ToolDefinition) => INTEGRATIONS.find((ig) => ig.name === tool.name), []); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState(emptyForm); - const [collapsedCategories, setCollapsedCategories] = useState>( Object.fromEntries([ ...CATEGORY_ORDER.map((cat) => [cat, true]), @@ -78,167 +61,10 @@ const Tools: React.FC = () => { const [coreSectionOpen, setCoreSectionOpen] = useState(false); const [deferredSectionOpen, setDeferredSectionOpen] = useState(false); const [customSectionOpen, setCustomSectionOpen] = useState(true); - - const [menuAnchor, setMenuAnchor] = useState(null); - - 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<'' | 'community' | 'google' | 'curated'>('curated'); - - // Curated whitelist matches the MCPSearch alias map in main.py (mcp-meta). - const CURATED_MCP_NAMES = useMemo(() => new Set([ - 'google-workspace', 'microsoft-365', 'slack', 'discord', - 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', - ]), []); - const regServers = useMemo(() => { - if (regSource !== 'curated') return regServersRaw; - return regServersRaw.filter((srv: any) => { - const id = (srv?.name || srv?.id || '').toLowerCase(); - return CURATED_MCP_NAMES.has(id); - }); - }, [regServersRaw, regSource, CURATED_MCP_NAMES]); - const [expandedServer, setExpandedServer] = useState(null); - const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: 'success' | 'error' }>({ 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); - - const [integrationLoading, setIntegrationLoading] = useState>({}); - - 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 getInstalledIntegration = useCallback((integration: Integration): ToolDefinition | undefined => { - return allTools.find((t) => t.name === integration.name); - }, [allTools]); - - const handleIntegrationToggle = async (integration: Integration) => { - const existing = getInstalledIntegration(integration); - setIntegrationLoading((p) => ({ ...p, [integration.id]: true })); - try { - if (existing && existing.enabled !== false) { - await dispatch(updateTool({ id: existing.id, enabled: false })); - setSnackbar({ open: true, message: `Disabled ${integration.name}` }); - } else if (existing && existing.enabled === false) { - await dispatch(updateTool({ id: existing.id, enabled: true })); - if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') { - setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` }); - } else { - setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering actions…` }); - const discoverResult = await dispatch(discoverTools(existing.id)); - if (discoverTools.fulfilled.match(discoverResult)) { - setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` }); - } else { - const detail = (discoverResult as any).error?.message || 'discovery failed'; - setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); - } - } - } else { - const result = await dispatch(createTool({ - name: integration.name, - description: integration.description, - command: '', - mcp_config: integration.mcp_config, - credentials: {}, - auth_type: integration.authType || 'none', - auth_status: 'configured', - })); - if (createTool.fulfilled.match(result)) { - const newTool = result.payload; - if (integration.authType === 'oauth2' || integration.authType === 'device_code') { - setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` }); - } else { - setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering actions…` }); - const discoverResult = await dispatch(discoverTools(newTool.id)); - if (discoverTools.fulfilled.match(discoverResult)) { - setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` }); - } else { - const detail = (discoverResult as any).error?.message - || `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`; - setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); - } - } - } - } - } finally { - setIntegrationLoading((p) => ({ ...p, [integration.id]: false })); - } - }; - - const handleDiscover = async (toolId: string) => { - setDiscovering(true); - try { - const result = await dispatch(discoverTools(toolId)); - if (discoverTools.fulfilled.match(result)) { - setSnackbar({ open: true, message: 'Actions discovered successfully' }); - } else { - const detail = (result as any).error?.message || 'Discovery failed; is the MCP server running?'; - setSnackbar({ open: true, message: detail, severity: 'error' }); - } - } finally { - setDiscovering(false); - } - }; - - const handlePermissionChange = async (toolId: string, toolName: string, policy: string) => { - const tool = items[toolId]; - if (!tool) return; - const updated = { ...tool.tool_permissions, [toolName]: policy }; - await dispatch(updateTool({ id: toolId, tool_permissions: updated })); - }; - - const handleGroupPermissionChange = async (toolId: string, names: string[], policy: string) => { - const tool = items[toolId]; - if (!tool) return; - const updated = { ...tool.tool_permissions }; - for (const name of names) updated[name] = policy; - await dispatch(updateTool({ id: toolId, tool_permissions: updated })); - }; - - const handleBulkReadOnly = async (toolId: string) => { - const tool = items[toolId]; - if (!tool?.tool_permissions?._categories) return; - const readNames: string[] = tool.tool_permissions._categories.read || []; - const updated = { ...tool.tool_permissions }; - for (const name of readNames) updated[name] = 'always_allow'; - await dispatch(updateTool({ id: toolId, tool_permissions: updated })); - }; - - const handleResetPermissions = async (toolId: string) => { - const tool = items[toolId]; - if (!tool?.tool_permissions) return; - const updated = { ...tool.tool_permissions }; - for (const key of Object.keys(updated)) { - if (!key.startsWith('_')) updated[key] = 'ask'; - } - await dispatch(updateTool({ id: toolId, tool_permissions: updated })); - }; - - const [expandedServices, setExpandedServices] = useState>({}); - const [expandedSchema, setExpandedSchema] = useState(null); - const [browserSectionOpen, setBrowserSectionOpen] = useState(false); const [browserCollapsed, setBrowserCollapsed] = useState>({ browser_delegation: true, browser_action: true }); const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true); + const [menuAnchor, setMenuAnchor] = useState(null); useEffect(() => { dispatch(fetchTools()); @@ -246,48 +72,10 @@ const Tools: React.FC = () => { dispatch(fetchBuiltinPermissions()); }, [dispatch]); - const handleBuiltinPermissionChange = async (toolName: string, policy: string) => { - await dispatch(updateBuiltinPermissions({ [toolName]: policy })); - }; - - const handleBuiltinCategoryPermissionChange = async (toolNames: string[], policy: string) => { - const perms: Record = {}; - for (const name of toolNames) perms[name] = policy; - await dispatch(updateBuiltinPermissions(perms)); - }; - - const BROWSER_CATEGORIES = new Set(['browser_delegation', 'browser_action']); - const coreTools = useMemo(() => builtinTools.filter((bt) => !bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); - const deferredTools = useMemo(() => builtinTools.filter((bt) => bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); - const browserTools = useMemo(() => builtinTools.filter((bt) => BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); - const browserDelegationTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_delegation'), [browserTools]); - const browserActionTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_action'), [browserTools]); - const groupTools = (list: BuiltinTool[]) => { - const g: Record = {}; - for (const bt of list) { if (!g[bt.category]) g[bt.category] = []; g[bt.category].push(bt); } - return g; - }; - const groupedCore = useMemo(() => groupTools(coreTools), [coreTools]); - const groupedDeferred = useMemo(() => groupTools(deferredTools), [deferredTools]); - - const coreSectionEnabled = useMemo( - () => !coreTools.every((t) => builtinPermissions[t.name] === 'deny'), - [coreTools, builtinPermissions], - ); - const deferredSectionEnabled = useMemo( - () => !deferredTools.every((t) => builtinPermissions[t.name] === 'deny'), - [deferredTools, builtinPermissions], - ); - const browserSectionEnabled = useMemo( - () => browserTools.length > 0 && !browserTools.every((t) => builtinPermissions[t.name] === 'deny'), - [browserTools, builtinPermissions], - ); - - const handleSectionEnabledChange = async (tools: BuiltinTool[], enabled: boolean) => { - const perms: Record = {}; - for (const t of tools) perms[t.name] = enabled ? 'always_allow' : 'deny'; - await dispatch(updateBuiltinPermissions(perms)); - }; + const { + coreTools, deferredTools, browserTools, browserDelegationTools, browserActionTools, + groupedCore, groupedDeferred, coreSectionEnabled, deferredSectionEnabled, browserSectionEnabled, + } = useBuiltinSections(builtinTools, builtinPermissions); const toggleCategory = (cat: string) => setCollapsedCategories((p) => ({ ...p, [cat]: !p[cat] })); const toggleBuiltinExpand = (name: string) => setExpandedBuiltin((p) => (p === name ? null : name)); @@ -295,333 +83,20 @@ const Tools: React.FC = () => { const handleMenuOpen = (e: React.MouseEvent) => setMenuAnchor(e.currentTarget); const handleMenuClose = () => setMenuAnchor(null); - const openCreate = () => { - handleMenuClose(); - setEditingId(null); - setForm(emptyForm); - setDialogOpen(true); - }; + const a = useToolsActions({ items, allTools, regServersRaw, closeMenu: handleMenuClose }); - const openRegistryBrowser = () => { - handleMenuClose(); - 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 }); - setDialogOpen(true); - }; - - const handleSave = async () => { - const payload = { name: form.name, description: form.description, command: form.command }; - if (editingId) { await dispatch(updateTool({ id: editingId, ...payload })); } else { await dispatch(createTool(payload)); } - setDialogOpen(false); - }; - - 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: '' | 'community' | 'google' | 'curated'): '' | 'community' | 'google' => - s === 'curated' ? '' : s; - - const handleRegSearch = useCallback((q: string, sort?: 'name' | 'stars', source?: '' | 'community' | 'google' | 'curated') => { - 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: '' | 'community' | 'google' | 'curated') => { - 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` }); - } - }; + // Curated whitelist matches the MCPSearch alias map in main.py (mcp-meta). + const CURATED_MCP_NAMES = useMemo(() => new Set([ + 'google-workspace', 'microsoft-365', 'slack', 'discord', + 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', + ]), []); + const regServers = useMemo(() => { + if (a.regSource !== 'curated') return regServersRaw; + return regServersRaw.filter((srv: any) => { + const id = (srv?.name || srv?.id || '').toLowerCase(); + return CURATED_MCP_NAMES.has(id); + }); + }, [regServersRaw, a.regSource, CURATED_MCP_NAMES]); return ( @@ -646,11 +121,11 @@ const Tools: React.FC = () => { onClose={handleMenuClose} PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, mt: 0.5, minWidth: 200 } }} > - + Create Custom - + Browse MCP Registry @@ -672,11 +147,11 @@ const Tools: React.FC = () => { {coreTools.length > 0 && ( - } count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(coreTools, v)} /> + } count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(coreTools, v)} /> )} {deferredTools.length > 0 && ( - } count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(deferredTools, v)} /> + } count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(deferredTools, v)} /> )} {browserTools.length > 0 && ( @@ -690,9 +165,9 @@ const Tools: React.FC = () => { browserCollapsed={browserCollapsed} setBrowserCollapsed={setBrowserCollapsed} builtinPermissions={builtinPermissions} - onSectionEnabledChange={handleSectionEnabledChange} - onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} - onPermissionChange={handleBuiltinPermissionChange} + onSectionEnabledChange={a.handleSectionEnabledChange} + onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} + onPermissionChange={a.handleBuiltinPermissionChange} /> )} @@ -725,8 +200,8 @@ const Tools: React.FC = () => { ))} {tools.map((tool) => ( @@ -734,28 +209,28 @@ const Tools: React.FC = () => { key={tool.id} tool={tool} ig={getIntegrationForTool(tool)} - isExpanded={expandedToolId === tool.id} - onToggleExpand={(toolId, wasExpanded) => setExpandedToolId(wasExpanded ? null : toolId)} - expandedServices={expandedServices} - setExpandedServices={setExpandedServices} - expandedSchema={expandedSchema} - setExpandedSchema={setExpandedSchema} + isExpanded={a.expandedToolId === tool.id} + onToggleExpand={(toolId, wasExpanded) => a.setExpandedToolId(wasExpanded ? null : toolId)} + expandedServices={a.expandedServices} + setExpandedServices={a.setExpandedServices} + expandedSchema={a.expandedSchema} + setExpandedSchema={a.setExpandedSchema} devMode={devMode} - integrationLoading={integrationLoading} - discovering={discovering} - onPermissionChange={handlePermissionChange} - onGroupPermissionChange={handleGroupPermissionChange} - onBulkReadOnly={handleBulkReadOnly} - onResetPermissions={handleResetPermissions} - onDiscover={handleDiscover} - onIntegrationToggle={handleIntegrationToggle} - onOAuthConnect={handleOAuthConnect} - onDeviceCodeConnect={handleDeviceCodeConnect} - onM365Disconnect={handleM365Disconnect} - onDisconnectIntegration={handleDisconnectIntegration} - onOpenCredentialsDialog={openCredentialsDialog} - onEdit={openEdit} - onDelete={handleDelete} + integrationLoading={a.integrationLoading} + discovering={a.discovering} + onPermissionChange={a.handlePermissionChange} + onGroupPermissionChange={a.handleGroupPermissionChange} + onBulkReadOnly={a.handleBulkReadOnly} + onResetPermissions={a.handleResetPermissions} + onDiscover={a.handleDiscover} + onIntegrationToggle={a.handleIntegrationToggle} + onOAuthConnect={a.handleOAuthConnect} + onDeviceCodeConnect={a.handleDeviceCodeConnect} + onM365Disconnect={a.handleM365Disconnect} + onDisconnectIntegration={a.handleDisconnectIntegration} + onOpenCredentialsDialog={a.openCredentialsDialog} + onEdit={a.openEdit} + onDelete={a.handleDelete} /> ))} @@ -764,57 +239,31 @@ const Tools: React.FC = () => { setRegistryOpen(false)} + open={a.registryOpen} + onClose={() => a.setRegistryOpen(false)} regStats={regStats} - regSource={regSource} + regSource={a.regSource} devMode={devMode} - regQuery={regQuery} - onRegSearch={handleRegSearch} - regSort={regSort} - onRegSort={handleRegSort} - onRegSourceFilter={handleRegSourceFilter} + regQuery={a.regQuery} + onRegSearch={a.handleRegSearch} + regSort={a.regSort} + onRegSort={a.handleRegSort} + onRegSourceFilter={a.handleRegSourceFilter} regLoading={regLoading} regServers={regServers} regTotal={regTotal} allTools={allTools} - expandedServer={expandedServer} + expandedServer={a.expandedServer} onExpandServer={(srv, next) => { - setExpandedServer(next); + a.setExpandedServer(next); if (next && devMode) { dispatch(clearDetail()); dispatch(fetchServerDetail(srv.name)); @@ -822,19 +271,19 @@ const Tools: React.FC = () => { }} regDetail={regDetail} regDetailLoading={regDetailLoading} - onInstall={handleInstall} - onEditInstall={handleEditInstall} - onLoadMore={handleLoadMore} + onInstall={a.handleInstall} + onEditInstall={a.handleEditInstall} + onLoadMore={a.handleLoadMore} /> setSnackbar({ open: false, message: '' })} + onClose={() => a.setSnackbar({ open: false, message: '' })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setSnackbar({ open: false, message: '' })} severity={snackbar.severity || 'success'} sx={{ bgcolor: snackbar.severity === 'error' ? '#2e1a1a' : c.status.successBg, color: snackbar.severity === 'error' ? '#f87171' : c.status.success, border: `1px solid ${snackbar.severity === 'error' ? '#ef444440' : `${c.status.success}40`}` }}> - {snackbar.message} + a.setSnackbar({ open: false, message: '' })} severity={a.snackbar.severity || 'success'} sx={{ bgcolor: a.snackbar.severity === 'error' ? '#2e1a1a' : c.status.successBg, color: a.snackbar.severity === 'error' ? '#f87171' : c.status.success, border: `1px solid ${a.snackbar.severity === 'error' ? '#ef444440' : `${c.status.success}40`}` }}> + {a.snackbar.message} diff --git a/frontend/src/app/pages/Tools/useBuiltinSections.ts b/frontend/src/app/pages/Tools/useBuiltinSections.ts new file mode 100644 index 00000000..17314170 --- /dev/null +++ b/frontend/src/app/pages/Tools/useBuiltinSections.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { BuiltinTool } from '@/shared/state/toolsSlice'; + +const BROWSER_CATEGORIES = new Set(['browser_delegation', 'browser_action']); + +function groupTools(list: BuiltinTool[]) { + const g: Record = {}; + for (const bt of list) { if (!g[bt.category]) g[bt.category] = []; g[bt.category].push(bt); } + return g; +} + +const notDenied = (tools: BuiltinTool[], perms: Record) => + !tools.every((t) => perms[t.name] === 'deny'); + +export function useBuiltinSections(builtinTools: BuiltinTool[], builtinPermissions: Record) { + const coreTools = useMemo(() => builtinTools.filter((bt) => !bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); + const deferredTools = useMemo(() => builtinTools.filter((bt) => bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); + const browserTools = useMemo(() => builtinTools.filter((bt) => BROWSER_CATEGORIES.has(bt.category)), [builtinTools]); + const browserDelegationTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_delegation'), [browserTools]); + const browserActionTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_action'), [browserTools]); + const groupedCore = useMemo(() => groupTools(coreTools), [coreTools]); + const groupedDeferred = useMemo(() => groupTools(deferredTools), [deferredTools]); + + const coreSectionEnabled = useMemo(() => notDenied(coreTools, builtinPermissions), [coreTools, builtinPermissions]); + const deferredSectionEnabled = useMemo(() => notDenied(deferredTools, builtinPermissions), [deferredTools, builtinPermissions]); + const browserSectionEnabled = useMemo(() => browserTools.length > 0 && notDenied(browserTools, builtinPermissions), [browserTools, builtinPermissions]); + + return { + coreTools, deferredTools, browserTools, browserDelegationTools, browserActionTools, + groupedCore, groupedDeferred, coreSectionEnabled, deferredSectionEnabled, browserSectionEnabled, + }; +} diff --git a/frontend/src/app/pages/Tools/useToolsActions.ts b/frontend/src/app/pages/Tools/useToolsActions.ts new file mode 100644 index 00000000..58f9ef2a --- /dev/null +++ b/frontend/src/app/pages/Tools/useToolsActions.ts @@ -0,0 +1,543 @@ +import React, { useCallback, useRef, 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 { Integration } from './integrations'; +import { ToolForm, emptyForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers'; + +type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' }; +type RegSource = '' | 'community' | 'google' | 'curated'; + +interface ToolsActionsDeps { + items: Record; + allTools: ToolDefinition[]; + regServersRaw: McpServer[]; + closeMenu: () => void; +} + +export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: ToolsActionsDeps) { + const dispatch = useAppDispatch(); + + const [dialogOpen, setDialogOpen] = useState(false); + 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); + const [integrationLoading, setIntegrationLoading] = useState>({}); + 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 getInstalledIntegration = (integration: Integration): ToolDefinition | undefined => { + return allTools.find((t) => t.name === integration.name); + }; + + const handleIntegrationToggle = async (integration: Integration) => { + const existing = getInstalledIntegration(integration); + setIntegrationLoading((p) => ({ ...p, [integration.id]: true })); + try { + if (existing && existing.enabled !== false) { + await dispatch(updateTool({ id: existing.id, enabled: false })); + setSnackbar({ open: true, message: `Disabled ${integration.name}` }); + } else if (existing && existing.enabled === false) { + await dispatch(updateTool({ id: existing.id, enabled: true })); + if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') { + setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` }); + } else { + setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering actions…` }); + const discoverResult = await dispatch(discoverTools(existing.id)); + if (discoverTools.fulfilled.match(discoverResult)) { + setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` }); + } else { + const detail = (discoverResult as any).error?.message || 'discovery failed'; + setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); + } + } + } else { + const result = await dispatch(createTool({ + name: integration.name, + description: integration.description, + command: '', + mcp_config: integration.mcp_config, + credentials: {}, + auth_type: integration.authType || 'none', + auth_status: 'configured', + })); + if (createTool.fulfilled.match(result)) { + const newTool = result.payload; + if (integration.authType === 'oauth2' || integration.authType === 'device_code') { + setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` }); + } else { + setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering actions…` }); + const discoverResult = await dispatch(discoverTools(newTool.id)); + if (discoverTools.fulfilled.match(discoverResult)) { + setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` }); + } else { + const detail = (discoverResult as any).error?.message + || `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`; + setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' }); + } + } + } + } + } finally { + setIntegrationLoading((p) => ({ ...p, [integration.id]: false })); + } + }; + + const handleDiscover = async (toolId: string) => { + setDiscovering(true); + try { + const result = await dispatch(discoverTools(toolId)); + if (discoverTools.fulfilled.match(result)) { + setSnackbar({ open: true, message: 'Actions discovered successfully' }); + } else { + const detail = (result as any).error?.message || 'Discovery failed; is the MCP server running?'; + setSnackbar({ open: true, message: detail, severity: 'error' }); + } + } finally { + setDiscovering(false); + } + }; + + const handlePermissionChange = async (toolId: string, toolName: string, policy: string) => { + const tool = items[toolId]; + if (!tool) return; + const updated = { ...tool.tool_permissions, [toolName]: policy }; + await dispatch(updateTool({ id: toolId, tool_permissions: updated })); + }; + + const handleGroupPermissionChange = async (toolId: string, names: string[], policy: string) => { + const tool = items[toolId]; + if (!tool) return; + const updated = { ...tool.tool_permissions }; + for (const name of names) updated[name] = policy; + await dispatch(updateTool({ id: toolId, tool_permissions: updated })); + }; + + const handleBulkReadOnly = async (toolId: string) => { + const tool = items[toolId]; + if (!tool?.tool_permissions?._categories) return; + const readNames: string[] = tool.tool_permissions._categories.read || []; + const updated = { ...tool.tool_permissions }; + for (const name of readNames) updated[name] = 'always_allow'; + await dispatch(updateTool({ id: toolId, tool_permissions: updated })); + }; + + const handleResetPermissions = async (toolId: string) => { + const tool = items[toolId]; + if (!tool?.tool_permissions) return; + const updated = { ...tool.tool_permissions }; + for (const key of Object.keys(updated)) { + if (!key.startsWith('_')) updated[key] = 'ask'; + } + await dispatch(updateTool({ id: toolId, tool_permissions: updated })); + }; + + const handleBuiltinPermissionChange = async (toolName: string, policy: string) => { + await dispatch(updateBuiltinPermissions({ [toolName]: policy })); + }; + + const handleBuiltinCategoryPermissionChange = async (toolNames: string[], policy: string) => { + const perms: Record = {}; + for (const name of toolNames) perms[name] = policy; + await dispatch(updateBuiltinPermissions(perms)); + }; + + const handleSectionEnabledChange = async (tools: BuiltinTool[], enabled: boolean) => { + const perms: Record = {}; + for (const t of tools) perms[t.name] = enabled ? 'always_allow' : 'deny'; + await dispatch(updateBuiltinPermissions(perms)); + }; + + const openCreate = () => { + closeMenu(); + setEditingId(null); + setForm(emptyForm); + 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 }); + setDialogOpen(true); + }; + + const handleSave = async () => { + const payload = { name: form.name, description: form.description, command: form.command }; + if (editingId) { await dispatch(updateTool({ id: editingId, ...payload })); } else { await dispatch(createTool(payload)); } + setDialogOpen(false); + }; + + 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, + expandedToolId, setExpandedToolId, discovering, integrationLoading, + expandedServices, setExpandedServices, expandedSchema, setExpandedSchema, + deviceCodeDialogOpen, setDeviceCodeDialogOpen, deviceCode, deviceCodeUrl, deviceCodeStatus, + credDialogOpen, setCredDialogOpen, credDialogIntegration, credDialogValues, setCredDialogValues, credDialogSaving, + 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, + }; +}