[eric] split: extract Tools actions + builtin-section hooks, Tools under 300

This commit is contained in:
ciregenz
2026-05-23 04:54:21 -07:00
parent 9de932d0bb
commit d5d2c30c07
3 changed files with 649 additions and 625 deletions
+74 -625
View File
@@ -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<string | null>(null);
const [form, setForm] = useState<ToolForm>(emptyForm);
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>(
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 | HTMLElement>(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<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: 'success' | 'error' }>({ open: false, message: '' });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mcpConfigOpen, setMcpConfigOpen] = useState(false);
const [mcpConfigServer, setMcpConfigServer] = useState<McpServer | null>(null);
const [mcpAuthType, setMcpAuthType] = useState<'none' | 'env_vars'>('none');
const [mcpCredentials, setMcpCredentials] = useState<Record<string, string>>({});
const [mcpConfigJson, setMcpConfigJson] = useState('');
const [mcpConfigError, setMcpConfigError] = useState('');
const [expandedToolId, setExpandedToolId] = useState<string | null>(null);
const [discovering, setDiscovering] = useState(false);
const [integrationLoading, setIntegrationLoading] = useState<Record<string, boolean>>({});
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState<string | null>(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<string | null>(null);
const [credDialogIntegration, setCredDialogIntegration] = useState<Integration | null>(null);
const [credDialogValues, setCredDialogValues] = useState<Record<string, string>>({});
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<Record<string, boolean>>({});
const [expandedSchema, setExpandedSchema] = useState<string | null>(null);
const [browserSectionOpen, setBrowserSectionOpen] = useState(false);
const [browserCollapsed, setBrowserCollapsed] = useState<Record<string, boolean>>({ browser_delegation: true, browser_action: true });
const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(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<string, string> = {};
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<string, BuiltinTool[]> = {};
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<string, string> = {};
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<HTMLElement>) => 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<HTMLElement>, 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<string, any> = {};
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<string, string> = {};
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 (
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
@@ -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 } }}
>
<MenuItem onClick={openCreate} sx={{ color: c.text.primary, fontSize: '0.88rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<MenuItem onClick={a.openCreate} sx={{ color: c.text.primary, fontSize: '0.88rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<BuildIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Create Custom
</MenuItem>
<MenuItem onClick={openRegistryBrowser} sx={{ color: c.text.primary, fontSize: '0.88rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<MenuItem onClick={a.openRegistryBrowser} sx={{ color: c.text.primary, fontSize: '0.88rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<StorefrontIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Browse MCP Registry
</MenuItem>
@@ -672,11 +147,11 @@ const Tools: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{coreTools.length > 0 && (
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} 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)} />
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} 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 && (
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} 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)} />
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} 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 = () => {
<IntegrationGalleryCard
key={ig.id}
integration={ig}
isLoading={!!integrationLoading[ig.id]}
onToggle={handleIntegrationToggle}
isLoading={!!a.integrationLoading[ig.id]}
onToggle={a.handleIntegrationToggle}
/>
))}
{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}
/>
))}
</Box>
@@ -764,57 +239,31 @@ const Tools: React.FC = () => {
</Box>
<ToolDialogs
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
editingId={editingId}
form={form}
setForm={setForm}
onSave={handleSave}
mcpConfigOpen={mcpConfigOpen}
setMcpConfigOpen={setMcpConfigOpen}
mcpConfigServer={mcpConfigServer}
mcpConfigJson={mcpConfigJson}
setMcpConfigJson={setMcpConfigJson}
mcpConfigError={mcpConfigError}
setMcpConfigError={setMcpConfigError}
mcpAuthType={mcpAuthType}
setMcpAuthType={setMcpAuthType}
mcpCredentials={mcpCredentials}
setMcpCredentials={setMcpCredentials}
onMcpConfigSave={handleMcpConfigSave}
deviceCodeDialogOpen={deviceCodeDialogOpen}
setDeviceCodeDialogOpen={setDeviceCodeDialogOpen}
deviceCodeStatus={deviceCodeStatus}
deviceCodeUrl={deviceCodeUrl}
deviceCode={deviceCode}
credDialogOpen={credDialogOpen}
setCredDialogOpen={setCredDialogOpen}
credDialogIntegration={credDialogIntegration}
credDialogValues={credDialogValues}
setCredDialogValues={setCredDialogValues}
credDialogSaving={credDialogSaving}
onSlackAutoConnect={handleSlackAutoConnect}
onCredentialsSave={handleCredentialsSave}
{...a}
onSave={a.handleSave}
onMcpConfigSave={a.handleMcpConfigSave}
onSlackAutoConnect={a.handleSlackAutoConnect}
onCredentialsSave={a.handleCredentialsSave}
/>
<RegistryBrowserDialog
open={registryOpen}
onClose={() => 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}
/>
<Snackbar
open={snackbar.open}
open={a.snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ open: false, message: '' })}
onClose={() => a.setSnackbar({ open: false, message: '' })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => 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}
<Alert onClose={() => 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}
</Alert>
</Snackbar>
</Box>
@@ -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<string, BuiltinTool[]> = {};
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<string, string>) =>
!tools.every((t) => perms[t.name] === 'deny');
export function useBuiltinSections(builtinTools: BuiltinTool[], builtinPermissions: Record<string, string>) {
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,
};
}
@@ -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<string, ToolDefinition>;
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<string | null>(null);
const [form, setForm] = useState<ToolForm>(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<RegSource>('curated');
const [expandedServer, setExpandedServer] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<Snackbar>({ open: false, message: '' });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mcpConfigOpen, setMcpConfigOpen] = useState(false);
const [mcpConfigServer, setMcpConfigServer] = useState<McpServer | null>(null);
const [mcpAuthType, setMcpAuthType] = useState<'none' | 'env_vars'>('none');
const [mcpCredentials, setMcpCredentials] = useState<Record<string, string>>({});
const [mcpConfigJson, setMcpConfigJson] = useState('');
const [mcpConfigError, setMcpConfigError] = useState('');
const [expandedToolId, setExpandedToolId] = useState<string | null>(null);
const [discovering, setDiscovering] = useState(false);
const [integrationLoading, setIntegrationLoading] = useState<Record<string, boolean>>({});
const [expandedServices, setExpandedServices] = useState<Record<string, boolean>>({});
const [expandedSchema, setExpandedSchema] = useState<string | null>(null);
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState<string | null>(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<string | null>(null);
const [credDialogIntegration, setCredDialogIntegration] = useState<Integration | null>(null);
const [credDialogValues, setCredDialogValues] = useState<Record<string, string>>({});
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<string, string> = {};
for (const name of toolNames) perms[name] = policy;
await dispatch(updateBuiltinPermissions(perms));
};
const handleSectionEnabledChange = async (tools: BuiltinTool[], enabled: boolean) => {
const perms: Record<string, string> = {};
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<HTMLElement>, 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<string, any> = {};
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<string, string> = {};
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,
};
}