[eric] split: break oversized Tools children under 300 (ServiceGroup, dialogs, server row, connection + registry hooks)

This commit is contained in:
ciregenz
2026-05-23 05:04:59 -07:00
parent d5d2c30c07
commit 5171b7b13c
11 changed files with 1133 additions and 890 deletions
+32 -253
View File
@@ -17,17 +17,16 @@ import ExtensionIcon from '@mui/icons-material/Extension';
import SearchIcon from '@mui/icons-material/Search';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import LinkIcon from '@mui/icons-material/Link';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import BlockIcon from '@mui/icons-material/Block';
import VisibilityIcon from '@mui/icons-material/Visibility';
import SecurityIcon from '@mui/icons-material/Security';
import PanToolIcon from '@mui/icons-material/PanTool';
import RefreshIcon from '@mui/icons-material/Refresh';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { Integration } from './integrations';
import ServiceGroup from './ServiceGroup';
import CustomToolDevInfo from './CustomToolDevInfo';
import CustomToolConnect from './CustomToolConnect';
interface CustomToolCardProps {
tool: ToolDefinition;
@@ -87,170 +86,6 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
const hasPerms = serviceNames.length > 0;
const totalToolCount = serviceNames.reduce((acc, s) => acc + (services![s].read?.length || 0) + (services![s].write?.length || 0), 0);
const toDisplayName = (name: string, serviceName?: string) => {
let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
if (serviceName) {
const svcLower = serviceName.toLowerCase();
const variants = [svcLower, svcLower.replace(/s$/, '')];
for (const v of variants) {
display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim();
}
display = display.replace(/\s{2,}/g, ' ').trim();
}
return display;
};
const firstSentence = (desc: string) => {
if (!desc) return '';
const match = desc.match(/^(.+?(?:\.|$))/);
return match ? match[1].trim() : desc.substring(0, 100);
};
const getGroupPolicy = (names: string[]) => {
if (names.length === 0) return 'ask';
const policies = names.map((n) => perms[n] || 'ask');
if (policies.every((p) => p === 'always_allow')) return 'always_allow';
if (policies.every((p) => p === 'deny')) return 'deny';
if (policies.every((p) => p === 'ask')) return 'ask';
return 'mixed';
};
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
</Box>
);
const ServiceGroup = ({ serviceName, data, isFirstGroup }: { serviceName: string; data: { read?: string[]; write?: string[] }; isFirstGroup?: boolean }) => {
const svcKey = `${tool.id}:${serviceName}`;
const isOpen = expandedServices[svcKey] ?? false;
const allNames = [...(data.read || []), ...(data.write || [])];
const svcPolicy = getGroupPolicy(allNames);
const count = allNames.length;
const isReddit =
ig?.id === 'reddit' ||
tool.name?.toLowerCase() === 'reddit' ||
(tool.command || '').toLowerCase().includes('reddit');
const isYoutube =
ig?.id === 'youtube' ||
tool.name?.toLowerCase() === 'youtube' ||
(tool.command || '').toLowerCase().includes('youtube');
const isSubredditsForReddit =
isReddit && /subreddit/i.test(serviceName);
// YouTube marker lands on the first service group since YouTube has no drill-down.
const showPermissionMarker =
isSubredditsForReddit || (isYoutube && isFirstGroup);
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', '&:hover': { borderColor: `${c.border.medium}` } }}>
<Box
data-onboarding={isSubredditsForReddit ? 'actions-subreddits-chevron' : undefined}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 0.75, cursor: 'pointer', bgcolor: isOpen ? c.bg.secondary : 'transparent', '&:hover': { bgcolor: c.bg.secondary } }}
onClick={() => setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>{serviceName}</Typography>
<Chip label={count} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box data-onboarding={showPermissionMarker ? 'actions-permission-toggle' : undefined}>
<PermToggle value={svcPolicy === 'mixed' ? 'ask' : svcPolicy} onChange={(v) => handleGroupPermissionChange(tool.id, allNames, v)} />
</Box>
</Box>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{(data.read?.length || 0) > 0 && (
<Box sx={{ mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 0.5, py: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VisibilityIcon sx={{ fontSize: 12, color: c.status.info }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontWeight: 600 }}>Read-only</Typography>
<Chip label={data.read!.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.ghost, fontSize: '0.6rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />
</Box>
<PermToggle value={getGroupPolicy(data.read!) === 'mixed' ? 'ask' : getGroupPolicy(data.read!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.read!, v)} size={14} />
</Box>
{data.read!.map((name) => {
const schemaKey = `${tool.id}:${name}`;
const schema = schemas[name];
const schemaProps = schema?.properties as Record<string, any> | undefined;
const schemaRequired = (schema?.required || []) as string[];
return (
<Box key={name}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, cursor: devMode && schema ? 'pointer' : undefined, '&:hover': { bgcolor: c.bg.secondary } }} onClick={() => devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
{devMode && expandedSchema === schemaKey && schemaProps && (
<Box sx={{ mx: 1.5, mb: 0.75, px: 1.5, py: 1, bgcolor: c.bg.page, borderRadius: 1, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.65rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>Input Parameters</Typography>
{Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => (
<Box key={pName} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, py: 0.2 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.72rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>{pName}</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.68rem', fontFamily: c.font.mono }}>{pDef?.type || 'any'}</Typography>
{schemaRequired.includes(pName) && <Chip label="required" size="small" sx={{ bgcolor: `${c.status.error}12`, color: c.status.error, fontSize: '0.55rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />}
{pDef?.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pDef.description}</Typography>}
</Box>
))}
</Box>
)}
</Box>
);
})}
</Box>
)}
{(data.write?.length || 0) > 0 && (
<Box sx={{ mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 0.5, py: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<EditIcon sx={{ fontSize: 12, color: c.status.warning }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontWeight: 600 }}>Write / delete</Typography>
<Chip label={data.write!.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.ghost, fontSize: '0.6rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />
</Box>
<PermToggle value={getGroupPolicy(data.write!) === 'mixed' ? 'ask' : getGroupPolicy(data.write!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.write!, v)} size={14} />
</Box>
{data.write!.map((name) => {
const schemaKey = `${tool.id}:${name}`;
const schema = schemas[name];
const schemaProps = schema?.properties as Record<string, any> | undefined;
const schemaRequired = (schema?.required || []) as string[];
return (
<Box key={name}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, cursor: devMode && schema ? 'pointer' : undefined, '&:hover': { bgcolor: c.bg.secondary } }} onClick={() => devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
{devMode && expandedSchema === schemaKey && schemaProps && (
<Box sx={{ mx: 1.5, mb: 0.75, px: 1.5, py: 1, bgcolor: c.bg.page, borderRadius: 1, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.65rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>Input Parameters</Typography>
{Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => (
<Box key={pName} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, py: 0.2 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.72rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>{pName}</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.68rem', fontFamily: c.font.mono }}>{pDef?.type || 'any'}</Typography>
{schemaRequired.includes(pName) && <Chip label="required" size="small" sx={{ bgcolor: `${c.status.error}12`, color: c.status.error, fontSize: '0.55rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />}
{pDef?.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pDef.description}</Typography>}
</Box>
))}
</Box>
)}
</Box>
);
})}
</Box>
)}
</Box>
</Collapse>
</Box>
);
};
const isDisabled = tool.enabled === false;
// Defensive Reddit detection so onboarding hooks still attach when ig.id lookup fails (legacy/manual installs).
@@ -302,51 +137,16 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
</Box>
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
</Box>
{!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
{ig?.id === 'discord' && tool.auth_status === 'connected' ? 'Add server' : `Connect ${tool.name}`}
</Button>
)}
{!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleDeviceCodeConnect(tool.id); }}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect Microsoft 365
</Button>
)}
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); openCredentialsDialog(tool.id, ig); }}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
{ig.connectLabel || 'Connect'}
</Button>
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' ? 'Disconnect' : ''}>
<Chip
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'}
size="small"
onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined}
onClick={(e) => e.stopPropagation()}
sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }}
/>
</Tooltip>
)}
<CustomToolConnect
tool={tool}
ig={ig}
isDisabled={isDisabled}
onOAuthConnect={handleOAuthConnect}
onDeviceCodeConnect={handleDeviceCodeConnect}
onOpenCredentialsDialog={openCredentialsDialog}
onM365Disconnect={handleM365Disconnect}
onDisconnectIntegration={handleDisconnectIntegration}
/>
{ig && (
<Box
data-onboarding={
@@ -442,50 +242,29 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{serviceNames.map((svc, idx) => (
<ServiceGroup key={svc} serviceName={svc} data={services![svc]} isFirstGroup={idx === 0} />
<ServiceGroup
key={svc}
tool={tool}
ig={ig}
serviceName={svc}
data={services![svc]}
isFirstGroup={idx === 0}
perms={perms}
descriptions={descriptions}
schemas={schemas}
expandedServices={expandedServices}
setExpandedServices={setExpandedServices}
expandedSchema={expandedSchema}
setExpandedSchema={setExpandedSchema}
devMode={devMode}
onGroupPermissionChange={handleGroupPermissionChange}
onPermissionChange={handlePermissionChange}
/>
))}
</Box>
)}
{devMode && isMcp && (
<Box sx={{ mt: 2, pt: 1.5, borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Developer Info
</Typography>
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>
MCP Config
</Typography>
<Typography component="pre" sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono, whiteSpace: 'pre-wrap', wordBreak: 'break-all', m: 0, lineHeight: 1.5 }}>
{JSON.stringify(tool.mcp_config, null, 2)}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Auth type:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_type || 'none'}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Status:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_status || 'none'}</Typography>
</Box>
{tool.connected_account_email && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Account:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.connected_account_email}</Typography>
</Box>
)}
</Box>
{tool.credentials && Object.keys(tool.credentials).length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Credentials:</Typography>
{Object.keys(tool.credentials).map((key) => (
<Chip key={key} label={`${key}: configured`} size="small" sx={{ bgcolor: `${c.status.success}12`, color: c.status.success, fontSize: '0.65rem', height: 18, fontFamily: c.font.mono, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
</Box>
)}
{devMode && isMcp && <CustomToolDevInfo tool={tool} />}
</Box>
</Collapse>
</Card>
@@ -0,0 +1,82 @@
import React from 'react';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import LinkIcon from '@mui/icons-material/Link';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { Integration } from './integrations';
interface CustomToolConnectProps {
tool: ToolDefinition;
ig: Integration | undefined;
isDisabled: boolean;
onOAuthConnect: (toolId: string) => void;
onDeviceCodeConnect: (toolId: string) => void;
onOpenCredentialsDialog: (toolId: string, integration: Integration) => void;
onM365Disconnect: (toolId: string) => void;
onDisconnectIntegration: (toolId: string, integration: Integration) => void;
}
const CustomToolConnect: React.FC<CustomToolConnectProps> = ({
tool, ig, isDisabled,
onOAuthConnect: handleOAuthConnect,
onDeviceCodeConnect: handleDeviceCodeConnect,
onOpenCredentialsDialog: openCredentialsDialog,
onM365Disconnect: handleM365Disconnect,
onDisconnectIntegration: handleDisconnectIntegration,
}) => {
const c = useClaudeTokens();
return (
<>
{!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
{ig?.id === 'discord' && tool.auth_status === 'connected' ? 'Add server' : `Connect ${tool.name}`}
</Button>
)}
{!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleDeviceCodeConnect(tool.id); }}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect Microsoft 365
</Button>
)}
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); openCredentialsDialog(tool.id, ig); }}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
{ig.connectLabel || 'Connect'}
</Button>
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' ? 'Disconnect' : ''}>
<Chip
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'}
size="small"
onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined}
onClick={(e) => e.stopPropagation()}
sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }}
/>
</Tooltip>
)}
</>
);
};
export default CustomToolConnect;
@@ -0,0 +1,51 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const CustomToolDevInfo: React.FC<{ tool: ToolDefinition }> = ({ tool }) => {
const c = useClaudeTokens();
return (
<Box sx={{ mt: 2, pt: 1.5, borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Developer Info
</Typography>
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>
MCP Config
</Typography>
<Typography component="pre" sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono, whiteSpace: 'pre-wrap', wordBreak: 'break-all', m: 0, lineHeight: 1.5 }}>
{JSON.stringify(tool.mcp_config, null, 2)}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Auth type:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_type || 'none'}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Status:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_status || 'none'}</Typography>
</Box>
{tool.connected_account_email && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Account:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.connected_account_email}</Typography>
</Box>
)}
</Box>
{tool.credentials && Object.keys(tool.credentials).length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Credentials:</Typography>
{Object.keys(tool.credentials).map((key) => (
<Chip key={key} label={`${key}: configured`} size="small" sx={{ bgcolor: `${c.status.success}12`, color: c.status.success, fontSize: '0.65rem', height: 18, fontFamily: c.font.mono, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
</Box>
);
};
export default CustomToolDevInfo;
@@ -0,0 +1,145 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Avatar from '@mui/material/Avatar';
import ExtensionIcon from '@mui/icons-material/Extension';
import { McpServer } from '@/shared/state/mcpRegistrySlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cleanServerName } from './toolsHelpers';
interface McpConfigDialogProps {
open: boolean;
onClose: () => void;
mcpConfigServer: McpServer | null;
mcpConfigJson: string;
setMcpConfigJson: (json: string) => void;
mcpConfigError: string;
setMcpConfigError: (err: string) => void;
mcpAuthType: 'none' | 'env_vars';
setMcpAuthType: (val: 'none' | 'env_vars') => void;
mcpCredentials: Record<string, string>;
setMcpCredentials: (creds: Record<string, string>) => void;
onSave: () => void;
}
const McpConfigDialog: React.FC<McpConfigDialogProps> = ({
open, onClose, mcpConfigServer, mcpConfigJson, setMcpConfigJson,
mcpConfigError, setMcpConfigError, mcpAuthType, setMcpAuthType, mcpCredentials, setMcpCredentials, onSave,
}) => {
const c = useClaudeTokens();
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` } }}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<ExtensionIcon sx={{ color: c.status.warning }} />
Configure MCP Tool
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, pt: '8px !important' }}>
{mcpConfigServer && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.5, bgcolor: c.bg.page, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
<Avatar
src={mcpConfigServer.iconUrl || undefined}
sx={{ width: 32, height: 32, bgcolor: c.bg.secondary, fontSize: '0.8rem', fontWeight: 700, color: c.text.muted }}
>
{mcpConfigServer.iconUrl ? null : (mcpConfigServer.title || cleanServerName(mcpConfigServer.name)).charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
{mcpConfigServer.title || cleanServerName(mcpConfigServer.name)}
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}>{mcpConfigServer.description}</Typography>
</Box>
</Box>
)}
<TextField
label="MCP Config (JSON)"
value={mcpConfigJson}
onChange={(e) => { setMcpConfigJson(e.target.value); try { JSON.parse(e.target.value); setMcpConfigError(''); } catch { setMcpConfigError('Invalid JSON'); } }}
fullWidth
multiline
minRows={3}
maxRows={8}
error={!!mcpConfigError}
helperText={mcpConfigError || 'Transport config passed to claude_agent_sdk (type, url, command, args, etc.)'}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Authentication Type</InputLabel>
<Select
value={mcpAuthType}
label="Authentication Type"
onChange={(e) => {
const val = e.target.value as 'none' | 'env_vars';
setMcpAuthType(val);
if (val === 'env_vars') setMcpCredentials({ API_KEY: '' });
else setMcpCredentials({});
}}
sx={{ bgcolor: c.bg.page }}
>
<MenuItem value="none">None</MenuItem>
<MenuItem value="env_vars">API Keys / Env Vars</MenuItem>
</Select>
</FormControl>
{mcpAuthType !== 'none' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5, bgcolor: c.bg.page, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Environment Variables
</Typography>
{Object.entries(mcpCredentials).map(([key, val]) => (
<TextField
key={key}
label={key}
value={val}
onChange={(e) => setMcpCredentials({ ...mcpCredentials, [key]: e.target.value })}
fullWidth
size="small"
type={key.toLowerCase().includes('secret') ? 'password' : 'text'}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.elevated, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
))}
{mcpAuthType === 'env_vars' && (
<Button
size="small"
onClick={() => setMcpCredentials({ ...mcpCredentials, [`VAR_${Object.keys(mcpCredentials).length + 1}`]: '' })}
sx={{ color: c.accent.primary, textTransform: 'none', fontSize: '0.78rem', alignSelf: 'flex-start' }}
>
+ Add Variable
</Button>
)}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ color: c.text.tertiary, textTransform: 'none' }}>Cancel</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!!mcpConfigError}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2 }}
>
Install Tool
</Button>
</DialogActions>
</Dialog>
);
};
export default McpConfigDialog;
@@ -11,27 +11,21 @@ import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import InputAdornment from '@mui/material/InputAdornment';
import Avatar from '@mui/material/Avatar';
import EditIcon from '@mui/icons-material/Edit';
import SearchIcon from '@mui/icons-material/Search';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import StorefrontIcon from '@mui/icons-material/Storefront';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import StarIcon from '@mui/icons-material/Star';
import SortIcon from '@mui/icons-material/Sort';
import CloudIcon from '@mui/icons-material/Cloud';
import PublicIcon from '@mui/icons-material/Public';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { McpServer, McpServerDetail } from '@/shared/state/mcpRegistrySlice';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { Skeleton } from '@/app/components/Loading';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cleanServerName } from './toolsHelpers';
import RegistryServerRow from './RegistryServerRow';
interface RegistryBrowserDialogProps {
open: boolean;
@@ -198,174 +192,21 @@ const RegistryBrowserDialog: React.FC<RegistryBrowserDialogProps> = ({
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 0.5 }}>
Showing {regServers.length} of {regTotal.toLocaleString()} results
</Typography>
{regServers.map((srv) => {
const isExpanded = expandedServer === srv.name;
const isInstalled = allTools.some((t) => t.name === (srv.title || cleanServerName(srv.name)));
return (
<Box key={srv.name}>
<Box
onClick={() => {
const next = isExpanded ? null : srv.name;
onExpandServer(srv, next);
}}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
px: 1.5, py: 1, borderRadius: 1.5, cursor: 'pointer',
transition: 'background 0.15s',
'&:hover': { bgcolor: c.bg.secondary },
...(isExpanded && { bgcolor: c.bg.secondary }),
}}
>
<Avatar
src={srv.iconUrl || undefined}
sx={{
width: 24, height: 24, flexShrink: 0, bgcolor: c.bg.secondary,
fontSize: '0.7rem', fontWeight: 700, color: c.text.muted,
}}
>
{srv.iconUrl ? null : (srv.title || cleanServerName(srv.name)).charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.88rem', fontFamily: c.font.mono }}>
{srv.title || cleanServerName(srv.name)}
</Typography>
{srv.version && <Chip label={`v${srv.version}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />}
{srv.remoteType && <Chip label={srv.remoteType} size="small" sx={{ bgcolor: '#3b82f615', color: '#3b82f6', fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />}
{srv.source === 'google' ? (
<Chip icon={<CloudIcon sx={{ fontSize: 12 }} />} label="Google" size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.info } }} />
) : (
<Chip icon={<PublicIcon sx={{ fontSize: 12 }} />} label="Community" size="small" sx={{ bgcolor: 'rgba(174,86,48,0.08)', color: c.accent.primary, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.accent.primary } }} />
)}
{srv.stars != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, ml: 0.5 }}>
<StarIcon sx={{ fontSize: 13, color: '#c89c00' }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, lineHeight: 1 }}>
{srv.stars >= 1000 ? `${(srv.stars / 1000).toFixed(1)}k` : srv.stars.toLocaleString()}
</Typography>
</Box>
)}
{devMode && !srv.remoteType && (
<Chip label="stdio" size="small" sx={{ bgcolor: '#8b5cf615', color: '#8b5cf6', fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{isInstalled && (
<Chip icon={<CheckCircleIcon sx={{ fontSize: 12 }} />} label="Installed" size="small" sx={{ bgcolor: `${c.status.success}15`, color: c.status.success, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.success } }} />
)}
</Box>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.78rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{srv.description}
</Typography>
</Box>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', flexShrink: 0 }} />
</Box>
<Collapse in={isExpanded} timeout={0} unmountOnExit>
<Box sx={{ ml: 4.5, mr: 1.5, mb: 1, px: 2, py: 1.5, bgcolor: c.bg.elevated, borderRadius: 1.5, borderLeft: '2px solid rgba(174,86,48,0.12)' }}>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 1.5, lineHeight: 1.5 }}>
{srv.description}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', fontFamily: c.font.mono }}>
{srv.name}
</Typography>
{srv.remoteUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', textTransform: 'uppercase' }}>Endpoint</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontFamily: c.font.mono }}>{srv.remoteUrl}</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
{srv.websiteUrl && (
<Chip
component="a"
href={srv.websiteUrl}
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Website"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
{srv.repositoryUrl && (
<Chip
component="a"
href={srv.repositoryUrl}
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Repository"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
</Box>
</Box>
{devMode && (
<Box sx={{ mb: 2 }}>
{regDetailLoading && expandedServer === srv.name ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : regDetail && regDetail.name === srv.name ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{(regDetail.keywords?.length > 0 || regDetail.license) && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
{regDetail.license && (
<Chip label={regDetail.license} size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{regDetail.keywords?.map((kw) => (
<Chip key={kw} label={kw} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
{regDetail.environmentVariables?.length > 0 && (
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.75 }}>
Required Environment Variables
</Typography>
{regDetail.environmentVariables.map((ev) => (
<Box key={ev.name} sx={{ display: 'flex', alignItems: 'baseline', gap: 1, py: 0.25 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.75rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>
{ev.name}
</Typography>
{ev.description && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>
{ev.description}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
) : null}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<DownloadIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleInstall(srv); }}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5 }}
>
Install
</Button>
<Button
size="small"
variant="outlined"
startIcon={<EditIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleEditInstall(srv); }}
sx={{ borderColor: c.border.strong, color: c.text.secondary, '&:hover': { borderColor: c.accent.primary, color: c.text.primary }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5 }}
>
Edit & Install
</Button>
</Box>
</Box>
</Collapse>
</Box>
);
})}
{regServers.map((srv) => (
<RegistryServerRow
key={srv.name}
srv={srv}
isExpanded={expandedServer === srv.name}
isInstalled={allTools.some((t) => t.name === (srv.title || cleanServerName(srv.name)))}
devMode={devMode}
regDetail={regDetail}
regDetailLoading={regDetailLoading}
expandedServer={expandedServer}
onExpand={onExpandServer}
onInstall={handleInstall}
onEditInstall={handleEditInstall}
/>
))}
{regServers.length < regTotal && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
@@ -0,0 +1,205 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Collapse from '@mui/material/Collapse';
import Avatar from '@mui/material/Avatar';
import EditIcon from '@mui/icons-material/Edit';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import StarIcon from '@mui/icons-material/Star';
import CloudIcon from '@mui/icons-material/Cloud';
import PublicIcon from '@mui/icons-material/Public';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { McpServer, McpServerDetail } from '@/shared/state/mcpRegistrySlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cleanServerName } from './toolsHelpers';
interface RegistryServerRowProps {
srv: McpServer;
isExpanded: boolean;
isInstalled: boolean;
devMode: boolean;
regDetail: McpServerDetail | null;
regDetailLoading: boolean;
expandedServer: string | null;
onExpand: (srv: McpServer, next: string | null) => void;
onInstall: (srv: McpServer) => void;
onEditInstall: (srv: McpServer) => void;
}
const RegistryServerRow: React.FC<RegistryServerRowProps> = ({
srv, isExpanded, isInstalled, devMode, regDetail, regDetailLoading, expandedServer,
onExpand, onInstall: handleInstall, onEditInstall: handleEditInstall,
}) => {
const c = useClaudeTokens();
return (
<Box key={srv.name}>
<Box
onClick={() => {
const next = isExpanded ? null : srv.name;
onExpand(srv, next);
}}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
px: 1.5, py: 1, borderRadius: 1.5, cursor: 'pointer',
transition: 'background 0.15s',
'&:hover': { bgcolor: c.bg.secondary },
...(isExpanded && { bgcolor: c.bg.secondary }),
}}
>
<Avatar
src={srv.iconUrl || undefined}
sx={{
width: 24, height: 24, flexShrink: 0, bgcolor: c.bg.secondary,
fontSize: '0.7rem', fontWeight: 700, color: c.text.muted,
}}
>
{srv.iconUrl ? null : (srv.title || cleanServerName(srv.name)).charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.88rem', fontFamily: c.font.mono }}>
{srv.title || cleanServerName(srv.name)}
</Typography>
{srv.version && <Chip label={`v${srv.version}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />}
{srv.remoteType && <Chip label={srv.remoteType} size="small" sx={{ bgcolor: '#3b82f615', color: '#3b82f6', fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />}
{srv.source === 'google' ? (
<Chip icon={<CloudIcon sx={{ fontSize: 12 }} />} label="Google" size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.info } }} />
) : (
<Chip icon={<PublicIcon sx={{ fontSize: 12 }} />} label="Community" size="small" sx={{ bgcolor: 'rgba(174,86,48,0.08)', color: c.accent.primary, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.accent.primary } }} />
)}
{srv.stars != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, ml: 0.5 }}>
<StarIcon sx={{ fontSize: 13, color: '#c89c00' }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, lineHeight: 1 }}>
{srv.stars >= 1000 ? `${(srv.stars / 1000).toFixed(1)}k` : srv.stars.toLocaleString()}
</Typography>
</Box>
)}
{devMode && !srv.remoteType && (
<Chip label="stdio" size="small" sx={{ bgcolor: '#8b5cf615', color: '#8b5cf6', fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{isInstalled && (
<Chip icon={<CheckCircleIcon sx={{ fontSize: 12 }} />} label="Installed" size="small" sx={{ bgcolor: `${c.status.success}15`, color: c.status.success, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.success } }} />
)}
</Box>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.78rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{srv.description}
</Typography>
</Box>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', flexShrink: 0 }} />
</Box>
<Collapse in={isExpanded} timeout={0} unmountOnExit>
<Box sx={{ ml: 4.5, mr: 1.5, mb: 1, px: 2, py: 1.5, bgcolor: c.bg.elevated, borderRadius: 1.5, borderLeft: '2px solid rgba(174,86,48,0.12)' }}>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 1.5, lineHeight: 1.5 }}>
{srv.description}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', fontFamily: c.font.mono }}>
{srv.name}
</Typography>
{srv.remoteUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem', textTransform: 'uppercase' }}>Endpoint</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontFamily: c.font.mono }}>{srv.remoteUrl}</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
{srv.websiteUrl && (
<Chip
component="a"
href={srv.websiteUrl}
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Website"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
{srv.repositoryUrl && (
<Chip
component="a"
href={srv.repositoryUrl}
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Repository"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
</Box>
</Box>
{devMode && (
<Box sx={{ mb: 2 }}>
{regDetailLoading && expandedServer === srv.name ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : regDetail && regDetail.name === srv.name ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{(regDetail.keywords?.length > 0 || regDetail.license) && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
{regDetail.license && (
<Chip label={regDetail.license} size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{regDetail.keywords?.map((kw) => (
<Chip key={kw} label={kw} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
{regDetail.environmentVariables?.length > 0 && (
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.75 }}>
Required Environment Variables
</Typography>
{regDetail.environmentVariables.map((ev) => (
<Box key={ev.name} sx={{ display: 'flex', alignItems: 'baseline', gap: 1, py: 0.25 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.75rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>
{ev.name}
</Typography>
{ev.description && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>
{ev.description}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
) : null}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<DownloadIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleInstall(srv); }}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5 }}
>
Install
</Button>
<Button
size="small"
variant="outlined"
startIcon={<EditIcon sx={{ fontSize: 14 }} />}
onClick={(e) => { e.stopPropagation(); handleEditInstall(srv); }}
sx={{ borderColor: c.border.strong, color: c.text.secondary, '&:hover': { borderColor: c.accent.primary, color: c.text.primary }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5 }}
>
Edit & Install
</Button>
</Box>
</Box>
</Collapse>
</Box>
);
};
export default RegistryServerRow;
@@ -0,0 +1,181 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import EditIcon from '@mui/icons-material/Edit';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import BlockIcon from '@mui/icons-material/Block';
import VisibilityIcon from '@mui/icons-material/Visibility';
import PanToolIcon from '@mui/icons-material/PanTool';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { Integration } from './integrations';
const toDisplayName = (name: string, serviceName?: string) => {
let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
if (serviceName) {
const svcLower = serviceName.toLowerCase();
const variants = [svcLower, svcLower.replace(/s$/, '')];
for (const v of variants) {
display = display.replace(new RegExp(`\\b${v}\\b`, 'gi'), '').trim();
}
display = display.replace(/\s{2,}/g, ' ').trim();
}
return display;
};
const firstSentence = (desc: string) => {
if (!desc) return '';
const match = desc.match(/^(.+?(?:\.|$))/);
return match ? match[1].trim() : desc.substring(0, 100);
};
interface ServiceGroupProps {
tool: ToolDefinition;
ig: Integration | undefined;
serviceName: string;
data: { read?: string[]; write?: string[] };
isFirstGroup?: boolean;
perms: Record<string, any>;
descriptions: Record<string, string>;
schemas: Record<string, any>;
expandedServices: Record<string, boolean>;
setExpandedServices: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
expandedSchema: string | null;
setExpandedSchema: React.Dispatch<React.SetStateAction<string | null>>;
devMode: boolean;
onGroupPermissionChange: (toolId: string, names: string[], policy: string) => void;
onPermissionChange: (toolId: string, toolName: string, policy: string) => void;
}
const ServiceGroup: React.FC<ServiceGroupProps> = ({
tool, ig, serviceName, data, isFirstGroup,
perms, descriptions, schemas,
expandedServices, setExpandedServices, expandedSchema, setExpandedSchema, devMode,
onGroupPermissionChange: handleGroupPermissionChange,
onPermissionChange: handlePermissionChange,
}) => {
const c = useClaudeTokens();
const getGroupPolicy = (names: string[]) => {
if (names.length === 0) return 'ask';
const policies = names.map((n) => perms[n] || 'ask');
if (policies.every((p) => p === 'always_allow')) return 'always_allow';
if (policies.every((p) => p === 'deny')) return 'deny';
if (policies.every((p) => p === 'ask')) return 'ask';
return 'mixed';
};
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => onChange('always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'always_allow' ? `${c.status.success}20` : 'transparent', color: value === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => onChange('ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'ask' ? `${c.status.warning}20` : 'transparent', color: value === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => onChange('deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: value === 'deny' ? `${c.status.error}20` : 'transparent', color: value === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: size }} /></IconButton></Tooltip>
</Box>
);
const renderActionList = (names: string[]) => names.map((name) => {
const schemaKey = `${tool.id}:${name}`;
const schema = schemas[name];
const schemaProps = schema?.properties as Record<string, any> | undefined;
const schemaRequired = (schema?.required || []) as string[];
return (
<Box key={name}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, cursor: devMode && schema ? 'pointer' : undefined, '&:hover': { bgcolor: c.bg.secondary } }} onClick={() => devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
{devMode && expandedSchema === schemaKey && schemaProps && (
<Box sx={{ mx: 1.5, mb: 0.75, px: 1.5, py: 1, bgcolor: c.bg.page, borderRadius: 1, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.65rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>Input Parameters</Typography>
{Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => (
<Box key={pName} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, py: 0.2 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.72rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>{pName}</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.68rem', fontFamily: c.font.mono }}>{pDef?.type || 'any'}</Typography>
{schemaRequired.includes(pName) && <Chip label="required" size="small" sx={{ bgcolor: `${c.status.error}12`, color: c.status.error, fontSize: '0.55rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />}
{pDef?.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pDef.description}</Typography>}
</Box>
))}
</Box>
)}
</Box>
);
});
const svcKey = `${tool.id}:${serviceName}`;
const isOpen = expandedServices[svcKey] ?? false;
const allNames = [...(data.read || []), ...(data.write || [])];
const svcPolicy = getGroupPolicy(allNames);
const count = allNames.length;
const isReddit =
ig?.id === 'reddit' ||
tool.name?.toLowerCase() === 'reddit' ||
(tool.command || '').toLowerCase().includes('reddit');
const isYoutube =
ig?.id === 'youtube' ||
tool.name?.toLowerCase() === 'youtube' ||
(tool.command || '').toLowerCase().includes('youtube');
const isSubredditsForReddit =
isReddit && /subreddit/i.test(serviceName);
// YouTube marker lands on the first service group since YouTube has no drill-down.
const showPermissionMarker =
isSubredditsForReddit || (isYoutube && isFirstGroup);
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', '&:hover': { borderColor: `${c.border.medium}` } }}>
<Box
data-onboarding={isSubredditsForReddit ? 'actions-subreddits-chevron' : undefined}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 0.75, cursor: 'pointer', bgcolor: isOpen ? c.bg.secondary : 'transparent', '&:hover': { bgcolor: c.bg.secondary } }}
onClick={() => setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>{serviceName}</Typography>
<Chip label={count} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box data-onboarding={showPermissionMarker ? 'actions-permission-toggle' : undefined}>
<PermToggle value={svcPolicy === 'mixed' ? 'ask' : svcPolicy} onChange={(v) => handleGroupPermissionChange(tool.id, allNames, v)} />
</Box>
</Box>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{(data.read?.length || 0) > 0 && (
<Box sx={{ mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 0.5, py: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VisibilityIcon sx={{ fontSize: 12, color: c.status.info }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontWeight: 600 }}>Read-only</Typography>
<Chip label={data.read!.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.ghost, fontSize: '0.6rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />
</Box>
<PermToggle value={getGroupPolicy(data.read!) === 'mixed' ? 'ask' : getGroupPolicy(data.read!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.read!, v)} size={14} />
</Box>
{renderActionList(data.read!)}
</Box>
)}
{(data.write?.length || 0) > 0 && (
<Box sx={{ mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 0.5, py: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<EditIcon sx={{ fontSize: 12, color: c.status.warning }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontWeight: 600 }}>Write / delete</Typography>
<Chip label={data.write!.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.ghost, fontSize: '0.6rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />
</Box>
<PermToggle value={getGroupPolicy(data.write!) === 'mixed' ? 'ask' : getGroupPolicy(data.write!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.write!, v)} size={14} />
</Box>
{renderActionList(data.write!)}
</Box>
)}
</Box>
</Collapse>
</Box>
);
};
export default ServiceGroup;
+14 -106
View File
@@ -7,19 +7,14 @@ import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import CircularProgress from '@mui/material/CircularProgress';
import Avatar from '@mui/material/Avatar';
import ExtensionIcon from '@mui/icons-material/Extension';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import LinkIcon from '@mui/icons-material/Link';
import { McpServer } from '@/shared/state/mcpRegistrySlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { Integration } from './integrations';
import { ToolForm, cleanServerName } from './toolsHelpers';
import { ToolForm } from './toolsHelpers';
import McpConfigDialog from './McpConfigDialog';
interface ToolDialogsProps {
dialogOpen: boolean;
@@ -83,107 +78,20 @@ const ToolDialogs: React.FC<ToolDialogsProps> = ({
</DialogActions>
</Dialog>
<Dialog
<McpConfigDialog
open={mcpConfigOpen}
onClose={() => setMcpConfigOpen(false)}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` } }}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<ExtensionIcon sx={{ color: c.status.warning }} />
Configure MCP Tool
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, pt: '8px !important' }}>
{mcpConfigServer && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.5, bgcolor: c.bg.page, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
<Avatar
src={mcpConfigServer.iconUrl || undefined}
sx={{ width: 32, height: 32, bgcolor: c.bg.secondary, fontSize: '0.8rem', fontWeight: 700, color: c.text.muted }}
>
{mcpConfigServer.iconUrl ? null : (mcpConfigServer.title || cleanServerName(mcpConfigServer.name)).charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
{mcpConfigServer.title || cleanServerName(mcpConfigServer.name)}
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.78rem' }}>{mcpConfigServer.description}</Typography>
</Box>
</Box>
)}
<TextField
label="MCP Config (JSON)"
value={mcpConfigJson}
onChange={(e) => { setMcpConfigJson(e.target.value); try { JSON.parse(e.target.value); setMcpConfigError(''); } catch { setMcpConfigError('Invalid JSON'); } }}
fullWidth
multiline
minRows={3}
maxRows={8}
error={!!mcpConfigError}
helperText={mcpConfigError || 'Transport config passed to claude_agent_sdk (type, url, command, args, etc.)'}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Authentication Type</InputLabel>
<Select
value={mcpAuthType}
label="Authentication Type"
onChange={(e) => {
const val = e.target.value as 'none' | 'env_vars';
setMcpAuthType(val);
if (val === 'env_vars') setMcpCredentials({ API_KEY: '' });
else setMcpCredentials({});
}}
sx={{ bgcolor: c.bg.page }}
>
<MenuItem value="none">None</MenuItem>
<MenuItem value="env_vars">API Keys / Env Vars</MenuItem>
</Select>
</FormControl>
{mcpAuthType !== 'none' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5, bgcolor: c.bg.page, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Environment Variables
</Typography>
{Object.entries(mcpCredentials).map(([key, val]) => (
<TextField
key={key}
label={key}
value={val}
onChange={(e) => setMcpCredentials({ ...mcpCredentials, [key]: e.target.value })}
fullWidth
size="small"
type={key.toLowerCase().includes('secret') ? 'password' : 'text'}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.elevated, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
))}
{mcpAuthType === 'env_vars' && (
<Button
size="small"
onClick={() => setMcpCredentials({ ...mcpCredentials, [`VAR_${Object.keys(mcpCredentials).length + 1}`]: '' })}
sx={{ color: c.accent.primary, textTransform: 'none', fontSize: '0.78rem', alignSelf: 'flex-start' }}
>
+ Add Variable
</Button>
)}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setMcpConfigOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>Cancel</Button>
<Button
variant="contained"
onClick={handleMcpConfigSave}
disabled={!!mcpConfigError}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2 }}
>
Install Tool
</Button>
</DialogActions>
</Dialog>
mcpConfigServer={mcpConfigServer}
mcpConfigJson={mcpConfigJson}
setMcpConfigJson={setMcpConfigJson}
mcpConfigError={mcpConfigError}
setMcpConfigError={setMcpConfigError}
mcpAuthType={mcpAuthType}
setMcpAuthType={setMcpAuthType}
mcpCredentials={mcpCredentials}
setMcpCredentials={setMcpCredentials}
onSave={handleMcpConfigSave}
/>
<Dialog
open={deviceCodeDialogOpen}
@@ -0,0 +1,173 @@
import { useCallback, useRef, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { createTool, discoverTools } from '@/shared/state/toolsSlice';
import { searchRegistry, fetchRegistryStats, McpServer } from '@/shared/state/mcpRegistrySlice';
import { ToolForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers';
type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' };
type RegSource = '' | 'community' | 'google' | 'curated';
interface Deps {
regServersRaw: McpServer[];
setSnackbar: (s: Snackbar) => void;
setEditingId: (id: string | null) => void;
setForm: (f: ToolForm) => void;
setDialogOpen: (v: boolean) => void;
closeMenu: () => void;
}
export function useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, setForm, setDialogOpen, closeMenu }: Deps) {
const dispatch = useAppDispatch();
const [registryOpen, setRegistryOpen] = useState(false);
const [regQuery, setRegQuery] = useState('');
const [regSort, setRegSort] = useState<'name' | 'stars'>('stars');
// Default 'curated' hides the long tail; client-side filter, backend still returns the full list.
const [regSource, setRegSource] = useState<RegSource>('curated');
const [expandedServer, setExpandedServer] = useState<string | null>(null);
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('');
// Translate UI "curated" pseudo-source to "" for the backend; the whitelist is applied client-side.
const _backendSource = (s: RegSource): '' | 'community' | 'google' =>
s === 'curated' ? '' : s;
const openRegistryBrowser = () => {
closeMenu();
setRegistryOpen(true);
setRegQuery('');
setRegSort('stars');
setRegSource('');
setExpandedServer(null);
dispatch(fetchRegistryStats());
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' }));
};
const handleRegSearch = useCallback((q: string, sort?: 'name' | 'stars', source?: RegSource) => {
setRegQuery(q);
setExpandedServer(null);
const sortVal = sort ?? regSort;
const sourceVal = source ?? regSource;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
dispatch(searchRegistry({ q, limit: 20, offset: 0, sort: sortVal, source: _backendSource(sourceVal) }));
}, 300);
}, [dispatch, regSort, regSource]);
const handleLoadMore = () => {
dispatch(searchRegistry({ q: regQuery, limit: 20, offset: regServersRaw.length, sort: regSort, source: _backendSource(regSource) }));
};
const handleRegSort = (sort: 'name' | 'stars') => {
setRegSort(sort);
setExpandedServer(null);
dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort, source: _backendSource(regSource) }));
};
const handleRegSourceFilter = (_: React.MouseEvent<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);
};
return {
registryOpen, setRegistryOpen, regQuery, regSort, regSource, expandedServer, setExpandedServer,
mcpConfigOpen, setMcpConfigOpen, mcpConfigServer, mcpAuthType, setMcpAuthType, mcpCredentials, setMcpCredentials,
mcpConfigJson, setMcpConfigJson, mcpConfigError, setMcpConfigError,
openRegistryBrowser, handleRegSearch, handleLoadMore, handleRegSort, handleRegSourceFilter,
handleMcpConfigSave, handleInstall, handleEditInstall,
};
}
@@ -0,0 +1,223 @@
import { useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import {
updateTool,
fetchToolStatus,
discoverTools,
startOAuth,
startDeviceCodeLogin,
pollDeviceCodeStatus,
disconnectM365,
ToolDefinition,
} from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { Integration } from './integrations';
type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' };
interface Deps {
items: Record<string, ToolDefinition>;
setSnackbar: (s: Snackbar) => void;
setExpandedToolId: (id: string | null) => void;
}
export function useToolConnections({ items, setSnackbar, setExpandedToolId }: Deps) {
const dispatch = useAppDispatch();
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState<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 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 {
deviceCodeDialogOpen, setDeviceCodeDialogOpen, deviceCode, deviceCodeUrl, deviceCodeStatus,
credDialogOpen, setCredDialogOpen, credDialogIntegration, credDialogValues, setCredDialogValues, credDialogSaving,
handleOAuthConnect, handleDeviceCodeConnect, handleM365Disconnect,
openCredentialsDialog, handleCredentialsSave, handleSlackAutoConnect, handleDisconnectIntegration,
};
}
+11 -356
View File
@@ -1,30 +1,21 @@
import React, { useCallback, useRef, useState } from 'react';
import { useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import {
createTool,
updateTool,
deleteTool,
startOAuth,
fetchToolStatus,
discoverTools,
startDeviceCodeLogin,
pollDeviceCodeStatus,
disconnectM365,
updateBuiltinPermissions,
ToolDefinition,
BuiltinTool,
} from '@/shared/state/toolsSlice';
import {
searchRegistry,
fetchRegistryStats,
McpServer,
} from '@/shared/state/mcpRegistrySlice';
import { API_BASE } from '@/shared/config';
import { McpServer } from '@/shared/state/mcpRegistrySlice';
import { ToolForm, emptyForm } from './toolsHelpers';
import { Integration } from './integrations';
import { ToolForm, emptyForm, serverToToolForm, serverToMcpConfig } from './toolsHelpers';
import { useToolConnections } from './useToolConnections';
import { useRegistryBrowser } from './useRegistryBrowser';
type Snackbar = { open: boolean; message: string; severity?: 'success' | 'error' };
type RegSource = '' | 'community' | 'google' | 'curated';
interface ToolsActionsDeps {
items: Record<string, ToolDefinition>;
@@ -40,21 +31,7 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
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);
@@ -62,17 +39,8 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
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 connections = useToolConnections({ items, setSnackbar, setExpandedToolId });
const registry = useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, setForm, setDialogOpen, closeMenu });
const getInstalledIntegration = (integration: Integration): ToolDefinition | undefined => {
return allTools.find((t) => t.name === integration.name);
@@ -203,17 +171,6 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
setDialogOpen(true);
};
const openRegistryBrowser = () => {
closeMenu();
setRegistryOpen(true);
setRegQuery('');
setRegSort('stars');
setRegSource('');
setExpandedServer(null);
dispatch(fetchRegistryStats());
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' }));
};
const openEdit = (tool: ToolDefinition) => {
setEditingId(tool.id);
setForm({ name: tool.name, description: tool.description, command: tool.command });
@@ -228,316 +185,14 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
const handleDelete = async (id: string) => { await dispatch(deleteTool(id)); };
// Translate UI "curated" pseudo-source to "" for the backend; the whitelist is applied client-side.
const _backendSource = (s: RegSource): '' | 'community' | 'google' =>
s === 'curated' ? '' : s;
const handleRegSearch = useCallback((q: string, sort?: 'name' | 'stars', source?: RegSource) => {
setRegQuery(q);
setExpandedServer(null);
const sortVal = sort ?? regSort;
const sourceVal = source ?? regSource;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
dispatch(searchRegistry({ q, limit: 20, offset: 0, sort: sortVal, source: _backendSource(sourceVal) }));
}, 300);
}, [dispatch, regSort, regSource]);
const handleLoadMore = () => {
dispatch(searchRegistry({ q: regQuery, limit: 20, offset: regServersRaw.length, sort: regSort, source: _backendSource(regSource) }));
};
const handleRegSort = (sort: 'name' | 'stars') => {
setRegSort(sort);
setExpandedServer(null);
dispatch(searchRegistry({ q: regQuery, limit: 20, offset: 0, sort, source: _backendSource(regSource) }));
};
const handleRegSourceFilter = (_: React.MouseEvent<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,
dialogOpen, setDialogOpen, editingId, setEditingId, form, setForm, snackbar, setSnackbar,
expandedToolId, setExpandedToolId, discovering, integrationLoading,
expandedServices, setExpandedServices, expandedSchema, setExpandedSchema,
deviceCodeDialogOpen, setDeviceCodeDialogOpen, deviceCode, deviceCodeUrl, deviceCodeStatus,
credDialogOpen, setCredDialogOpen, credDialogIntegration, credDialogValues, setCredDialogValues, credDialogSaving,
...connections,
...registry,
handleIntegrationToggle, handleDiscover, handlePermissionChange, handleGroupPermissionChange,
handleBulkReadOnly, handleResetPermissions, handleBuiltinPermissionChange, handleBuiltinCategoryPermissionChange,
handleSectionEnabledChange, openCreate, openRegistryBrowser, openEdit, handleSave, handleDelete,
handleRegSearch, handleLoadMore, handleRegSort, handleRegSourceFilter, handleMcpConfigSave, handleInstall,
handleEditInstall, handleOAuthConnect, handleDeviceCodeConnect, handleM365Disconnect, openCredentialsDialog,
handleCredentialsSave, handleSlackAutoConnect, handleDisconnectIntegration,
handleSectionEnabledChange, openCreate, openEdit, handleSave, handleDelete,
};
}