mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-10-01 14:04:51 +02:00
[eric] split: break oversized Tools children under 300 (ServiceGroup, dialogs, server row, connection + registry hooks)
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user