mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
refactor: split mcp-servers-section into modular components
Refactored 1348-line monolithic file into proper folder structure following folder-pattern.md conventions: Structure: - components/ - UI components (card, header, settings, warning) - dialogs/ - 5 dialog components (add/edit, delete, import, json edit) - hooks/use-mcp-servers.ts - all state management & handlers - types.ts, constants.ts, utils.tsx - shared code Main file reduced from 1348 to 192 lines (composition only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
export { MCPServerHeader } from './mcp-server-header';
|
||||||
|
export { MCPPermissionSettings } from './mcp-permission-settings';
|
||||||
|
export { MCPToolsWarning } from './mcp-tools-warning';
|
||||||
|
export { MCPServerCard } from './mcp-server-card';
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
|
||||||
|
|
||||||
|
interface MCPPermissionSettingsProps {
|
||||||
|
mcpAutoApproveTools: boolean;
|
||||||
|
mcpUnrestrictedTools: boolean;
|
||||||
|
onAutoApproveChange: (checked: boolean) => void;
|
||||||
|
onUnrestrictedChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPPermissionSettings({
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
onAutoApproveChange,
|
||||||
|
onUnrestrictedChange,
|
||||||
|
}: MCPPermissionSettingsProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium">
|
||||||
|
Auto-approve MCP tools
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allow MCP tool calls without permission prompts (recommended)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="mcp-auto-approve"
|
||||||
|
checked={mcpAutoApproveTools}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
onAutoApproveChange(checked);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
}}
|
||||||
|
data-testid="mcp-auto-approve-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium">
|
||||||
|
Unrestricted tools
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allow all tools when MCP is enabled (don't filter to default set)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="mcp-unrestricted"
|
||||||
|
checked={mcpUnrestrictedTools}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
onUnrestrictedChange(checked);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
}}
|
||||||
|
data-testid="mcp-unrestricted-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import type { ServerTestState } from '../types';
|
||||||
|
import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils';
|
||||||
|
import { MCPToolsList } from '../mcp-tools-list';
|
||||||
|
|
||||||
|
interface MCPServerCardProps {
|
||||||
|
server: MCPServerConfig;
|
||||||
|
testState?: ServerTestState;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
onEditJson: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPServerCard({
|
||||||
|
server,
|
||||||
|
testState,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
onTest,
|
||||||
|
onToggleEnabled,
|
||||||
|
onEditJson,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: MCPServerCardProps) {
|
||||||
|
const Icon = getServerIcon(server.type);
|
||||||
|
const hasTools = testState?.tools && testState.tools.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border',
|
||||||
|
server.enabled !== false
|
||||||
|
? 'border-border/50 bg-accent/20'
|
||||||
|
: 'border-border/30 bg-muted/30 opacity-60'
|
||||||
|
)}
|
||||||
|
data-testid={`mcp-server-${server.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 gap-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 text-left min-w-0 flex-1',
|
||||||
|
hasTools && 'cursor-pointer hover:opacity-80'
|
||||||
|
)}
|
||||||
|
disabled={!hasTools}
|
||||||
|
>
|
||||||
|
{hasTools ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||||
|
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||||
|
{testState && getTestStatusIcon(testState.status)}
|
||||||
|
{testState?.status === 'success' && testState.tools && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||||
|
{testState.tools.length} tool{testState.tools.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{server.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{server.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
|
||||||
|
{server.type === 'stdio'
|
||||||
|
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||||
|
: maskSensitiveUrl(server.url || '')}
|
||||||
|
</div>
|
||||||
|
{testState?.status === 'error' && testState.error && (
|
||||||
|
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
|
||||||
|
{testState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={testState?.status === 'testing' || server.enabled === false}
|
||||||
|
data-testid={`mcp-server-test-${server.id}`}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
{testState?.status === 'testing' ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 text-xs">Test</span>
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={server.enabled !== false}
|
||||||
|
onCheckedChange={onToggleEnabled}
|
||||||
|
data-testid={`mcp-server-toggle-${server.id}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onEditJson}
|
||||||
|
title="Edit JSON"
|
||||||
|
data-testid={`mcp-server-json-${server.id}`}
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onEdit}
|
||||||
|
data-testid={`mcp-server-edit-${server.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
data-testid={`mcp-server-delete-${server.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasTools && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">Available Tools</div>
|
||||||
|
<MCPToolsList
|
||||||
|
tools={testState.tools!}
|
||||||
|
isLoading={testState.status === 'testing'}
|
||||||
|
error={testState.error}
|
||||||
|
className="max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MCPServerHeaderProps {
|
||||||
|
isRefreshing: boolean;
|
||||||
|
hasServers: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
onEditAllJson: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPServerHeader({
|
||||||
|
isRefreshing,
|
||||||
|
hasServers,
|
||||||
|
onRefresh,
|
||||||
|
onExport,
|
||||||
|
onEditAllJson,
|
||||||
|
onImport,
|
||||||
|
onAdd,
|
||||||
|
}: MCPServerHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 border-b border-border/50 bg-linear-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Plug className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">MCP Servers</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure Model Context Protocol servers to extend agent capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
data-testid="refresh-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
{hasServers && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onExport}
|
||||||
|
data-testid="export-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEditAllJson}
|
||||||
|
data-testid="edit-all-json-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Edit JSON
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onImport}
|
||||||
|
data-testid="import-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 mr-2" />
|
||||||
|
Import JSON
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onAdd} data-testid="add-mcp-server-button">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||||
|
|
||||||
|
interface MCPToolsWarningProps {
|
||||||
|
totalTools: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-6 mt-4 p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||||
|
High tool count detected ({totalTools} tools)
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance.
|
||||||
|
Consider disabling unused servers or removing unnecessary tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Patterns that indicate sensitive values in URLs or config
|
||||||
|
export const SENSITIVE_PARAM_PATTERNS = [
|
||||||
|
/api[-_]?key/i,
|
||||||
|
/api[-_]?token/i,
|
||||||
|
/auth/i,
|
||||||
|
/token/i,
|
||||||
|
/secret/i,
|
||||||
|
/password/i,
|
||||||
|
/credential/i,
|
||||||
|
/bearer/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maximum recommended MCP tools before performance degradation
|
||||||
|
export const MAX_RECOMMENDED_TOOLS = 80;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import type { ServerFormData, ServerType } from '../types';
|
||||||
|
|
||||||
|
interface AddEditServerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingServer: MCPServerConfig | null;
|
||||||
|
formData: ServerFormData;
|
||||||
|
onFormDataChange: (data: ServerFormData) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddEditServerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editingServer,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: AddEditServerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="mcp-server-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure an MCP server to extend agent capabilities with custom tools.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="server-name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="my-mcp-server"
|
||||||
|
data-testid="mcp-server-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-description">Description (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="What this server provides..."
|
||||||
|
data-testid="mcp-server-description-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-type">Transport Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(value: ServerType) => onFormDataChange({ ...formData, type: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="server-type" data-testid="mcp-server-type-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stdio">Stdio (subprocess)</SelectItem>
|
||||||
|
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
||||||
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{formData.type === 'stdio' ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-command">Command</Label>
|
||||||
|
<Input
|
||||||
|
id="server-command"
|
||||||
|
value={formData.command}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, command: e.target.value })}
|
||||||
|
placeholder="npx, node, python, etc."
|
||||||
|
data-testid="mcp-server-command-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-args">Arguments (space-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-args"
|
||||||
|
value={formData.args}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, args: e.target.value })}
|
||||||
|
placeholder="-y @modelcontextprotocol/server-filesystem"
|
||||||
|
data-testid="mcp-server-args-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-env">Environment Variables (JSON, optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="server-env"
|
||||||
|
value={formData.env}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, env: e.target.value })}
|
||||||
|
placeholder={'{\n "API_KEY": "your-key"\n}'}
|
||||||
|
className="font-mono text-sm h-24"
|
||||||
|
data-testid="mcp-server-env-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, url: e.target.value })}
|
||||||
|
placeholder="https://example.com/mcp"
|
||||||
|
data-testid="mcp-server-url-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-headers">Headers (JSON, optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="server-headers"
|
||||||
|
value={formData.headers}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, headers: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
'{\n "x-api-key": "your-api-key",\n "Authorization": "Bearer token"\n}'
|
||||||
|
}
|
||||||
|
className="font-mono text-sm h-24"
|
||||||
|
data-testid="mcp-server-headers-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} data-testid="mcp-server-save-button">
|
||||||
|
{editingServer ? 'Save Changes' : 'Add Server'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface DeleteServerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteServerDialog({ open, onOpenChange, onConfirm }: DeleteServerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="mcp-server-delete-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this MCP server? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="mcp-server-confirm-delete-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Code } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface GlobalJsonEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
jsonValue: string;
|
||||||
|
onJsonValueChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalJsonEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
jsonValue,
|
||||||
|
onJsonValueChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: GlobalJsonEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh]" data-testid="mcp-global-json-edit-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit All MCP Servers</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Edit the full MCP servers configuration. Add, modify, or remove servers directly in
|
||||||
|
JSON. Servers removed from JSON will be deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"server-name": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
className="font-mono text-sm h-[50vh] min-h-[300px]"
|
||||||
|
data-testid="mcp-global-json-edit-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!jsonValue.trim()}
|
||||||
|
data-testid="mcp-global-json-edit-save-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Save All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { FileJson } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface ImportJsonDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
importJson: string;
|
||||||
|
onImportJsonChange: (value: string) => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportJsonDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
importJson,
|
||||||
|
onImportJsonChange,
|
||||||
|
onImport,
|
||||||
|
onCancel,
|
||||||
|
}: ImportJsonDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl" data-testid="mcp-import-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import MCP Servers</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste JSON configuration in Claude Code format. Servers with duplicate names will be
|
||||||
|
skipped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={importJson}
|
||||||
|
onChange={(e) => onImportJsonChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"server-name": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-name"],
|
||||||
|
"type": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
className="font-mono text-sm h-64"
|
||||||
|
data-testid="mcp-import-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onImport} disabled={!importJson.trim()} data-testid="mcp-import-button">
|
||||||
|
<FileJson className="w-4 h-4 mr-2" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { AddEditServerDialog } from './add-edit-server-dialog';
|
||||||
|
export { DeleteServerDialog } from './delete-server-dialog';
|
||||||
|
export { ImportJsonDialog } from './import-json-dialog';
|
||||||
|
export { JsonEditDialog } from './json-edit-dialog';
|
||||||
|
export { GlobalJsonEditDialog } from './global-json-edit-dialog';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Code } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
|
||||||
|
interface JsonEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
server: MCPServerConfig | null;
|
||||||
|
jsonValue: string;
|
||||||
|
onJsonValueChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
server,
|
||||||
|
jsonValue,
|
||||||
|
onJsonValueChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: JsonEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl" data-testid="mcp-json-edit-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Server Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Edit the raw JSON configuration for "{server?.name}". Changes will be validated before
|
||||||
|
saving.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||||
|
placeholder="Server configuration JSON..."
|
||||||
|
className="font-mono text-sm h-80"
|
||||||
|
data-testid="mcp-json-edit-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!jsonValue.trim()}
|
||||||
|
data-testid="mcp-json-edit-save-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Save JSON
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useMCPServers } from './use-mcp-servers';
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import type { ServerFormData, ServerTestState } from '../types';
|
||||||
|
import { defaultFormData } from '../types';
|
||||||
|
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||||
|
import type { ServerType } from '../types';
|
||||||
|
|
||||||
|
export function useMCPServers() {
|
||||||
|
const {
|
||||||
|
mcpServers,
|
||||||
|
addMCPServer,
|
||||||
|
updateMCPServer,
|
||||||
|
removeMCPServer,
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
setMcpAutoApproveTools,
|
||||||
|
setMcpUnrestrictedTools,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null);
|
||||||
|
const [formData, setFormData] = useState<ServerFormData>(defaultFormData);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [importJson, setImportJson] = useState('');
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [serverTestStates, setServerTestStates] = useState<Record<string, ServerTestState>>({});
|
||||||
|
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||||
|
const [jsonEditServer, setJsonEditServer] = useState<MCPServerConfig | null>(null);
|
||||||
|
const [jsonEditValue, setJsonEditValue] = useState('');
|
||||||
|
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
|
||||||
|
const [globalJsonValue, setGlobalJsonValue] = useState('');
|
||||||
|
const autoTestedServersRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const totalToolsCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
if (server.enabled !== false) {
|
||||||
|
const testState = serverTestStates[server.id];
|
||||||
|
if (testState?.status === 'success' && testState.tools) {
|
||||||
|
count += testState.tools.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [mcpServers, serverTestStates]);
|
||||||
|
|
||||||
|
const showToolsWarning = totalToolsCount > MAX_RECOMMENDED_TOOLS;
|
||||||
|
|
||||||
|
// Auto-load MCP servers from settings file on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadMCPServersFromServer().catch((error) => {
|
||||||
|
console.error('Failed to load MCP servers on mount:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Test a single server (extracted for reuse)
|
||||||
|
const testServer = useCallback(async (server: MCPServerConfig, silent = false) => {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: { status: 'testing' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.mcp.testServer(server.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'success',
|
||||||
|
tools: result.tools,
|
||||||
|
connectionTime: result.connectionTime,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// Only auto-expand on manual test, not on auto-test (silent)
|
||||||
|
if (!silent) {
|
||||||
|
setExpandedServers((prev) => new Set([...prev, server.id]));
|
||||||
|
toast.success(
|
||||||
|
`Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'error',
|
||||||
|
error: result.error,
|
||||||
|
connectionTime: result.connectionTime,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!silent) {
|
||||||
|
toast.error(`Failed to connect: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!silent) {
|
||||||
|
toast.error(`Test failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-test all enabled servers on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||||
|
const serversToTest = enabledServers.filter((s) => !autoTestedServersRef.current.has(s.id));
|
||||||
|
|
||||||
|
if (serversToTest.length > 0) {
|
||||||
|
// Mark all as being tested
|
||||||
|
serversToTest.forEach((s) => autoTestedServersRef.current.add(s.id));
|
||||||
|
|
||||||
|
// Test all servers in parallel (silently - no toast spam)
|
||||||
|
serversToTest.forEach((server) => {
|
||||||
|
testServer(server, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mcpServers, testServer]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const success = await loadMCPServersFromServer();
|
||||||
|
if (success) {
|
||||||
|
toast.success('MCP servers refreshed from settings');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to refresh MCP servers');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error refreshing MCP servers');
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestServer = (server: MCPServerConfig) => {
|
||||||
|
testServer(server, false); // false = show toast notifications
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleServerExpanded = (serverId: string) => {
|
||||||
|
setExpandedServers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(serverId)) {
|
||||||
|
next.delete(serverId);
|
||||||
|
} else {
|
||||||
|
next.add(serverId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddDialog = () => {
|
||||||
|
setFormData(defaultFormData);
|
||||||
|
setEditingServer(null);
|
||||||
|
setIsAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditDialog = (server: MCPServerConfig) => {
|
||||||
|
setFormData({
|
||||||
|
name: server.name,
|
||||||
|
description: server.description || '',
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
command: server.command || '',
|
||||||
|
args: server.args?.join(' ') || '',
|
||||||
|
url: server.url || '',
|
||||||
|
headers: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||||
|
env: server.env ? JSON.stringify(server.env, null, 2) : '',
|
||||||
|
});
|
||||||
|
setEditingServer(server);
|
||||||
|
setIsAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
setEditingServer(null);
|
||||||
|
setFormData(defaultFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error('Server name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.type === 'stdio' && !formData.command.trim()) {
|
||||||
|
toast.error('Command is required for stdio servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) {
|
||||||
|
toast.error('URL is required for SSE/HTTP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse headers if provided
|
||||||
|
let parsedHeaders: Record<string, string> | undefined;
|
||||||
|
if (formData.headers.trim()) {
|
||||||
|
try {
|
||||||
|
parsedHeaders = JSON.parse(formData.headers.trim());
|
||||||
|
if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) {
|
||||||
|
toast.error('Headers must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Invalid JSON for headers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse env if provided
|
||||||
|
let parsedEnv: Record<string, string> | undefined;
|
||||||
|
if (formData.env.trim()) {
|
||||||
|
try {
|
||||||
|
parsedEnv = JSON.parse(formData.env.trim());
|
||||||
|
if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) {
|
||||||
|
toast.error('Environment variables must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Invalid JSON for environment variables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
type: formData.type,
|
||||||
|
enabled: editingServer?.enabled ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.type === 'stdio') {
|
||||||
|
serverData.command = formData.command.trim();
|
||||||
|
if (formData.args.trim()) {
|
||||||
|
serverData.args = formData.args.trim().split(/\s+/);
|
||||||
|
}
|
||||||
|
if (parsedEnv) {
|
||||||
|
serverData.env = parsedEnv;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverData.url = formData.url.trim();
|
||||||
|
if (parsedHeaders) {
|
||||||
|
serverData.headers = parsedHeaders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingServer) {
|
||||||
|
updateMCPServer(editingServer.id, serverData);
|
||||||
|
toast.success('MCP server updated');
|
||||||
|
} else {
|
||||||
|
addMCPServer(serverData);
|
||||||
|
toast.success('MCP server added');
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncSettingsToServer();
|
||||||
|
handleCloseDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (server: MCPServerConfig) => {
|
||||||
|
updateMCPServer(server.id, { enabled: !server.enabled });
|
||||||
|
await syncSettingsToServer();
|
||||||
|
toast.success(server.enabled ? 'Server disabled' : 'Server enabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
removeMCPServer(id);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
toast.success('MCP server removed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportJson = async () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(importJson);
|
||||||
|
|
||||||
|
// Support both formats:
|
||||||
|
// 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } }
|
||||||
|
// 2. Direct format: { "name": { command, args, ... } }
|
||||||
|
const servers = parsed.mcpServers || parsed;
|
||||||
|
|
||||||
|
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||||
|
toast.error('Invalid format: expected object with server configurations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
if (typeof config !== 'object' || config === null) continue;
|
||||||
|
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check if server with this name already exists
|
||||||
|
if (mcpServers.some((s) => s.name === name)) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name,
|
||||||
|
type: (serverConfig.type as ServerType) || 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverData.type === 'stdio') {
|
||||||
|
if (!serverConfig.command) {
|
||||||
|
console.warn(`Skipping ${name}: no command specified`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serverData.command = serverConfig.command as string;
|
||||||
|
if (Array.isArray(serverConfig.args)) {
|
||||||
|
serverData.args = serverConfig.args as string[];
|
||||||
|
}
|
||||||
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||||
|
serverData.env = serverConfig.env as Record<string, string>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!serverConfig.url) {
|
||||||
|
console.warn(`Skipping ${name}: no url specified`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serverData.url = serverConfig.url as string;
|
||||||
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||||
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMCPServer(serverData);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncSettingsToServer();
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
toast.success(`Imported ${addedCount} MCP server${addedCount > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
toast.info(
|
||||||
|
`Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (addedCount === 0 && skippedCount === 0) {
|
||||||
|
toast.warning('No servers found in JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setImportJson('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJson = () => {
|
||||||
|
const exportData: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
const serverConfig: Record<string, unknown> = {
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
serverConfig.command = server.command;
|
||||||
|
if (server.args?.length) serverConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
serverConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0)
|
||||||
|
serverConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[server.name] = serverConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
|
||||||
|
navigator.clipboard.writeText(json);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenJsonEdit = (server: MCPServerConfig) => {
|
||||||
|
// Build a clean config object for editing (excluding internal fields like id)
|
||||||
|
const editableConfig: Record<string, unknown> = {
|
||||||
|
name: server.name,
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.description) {
|
||||||
|
editableConfig.description = server.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
if (server.command) editableConfig.command = server.command;
|
||||||
|
if (server.args?.length) editableConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) editableConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
if (server.url) editableConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||||
|
editableConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.enabled === false) {
|
||||||
|
editableConfig.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJsonEditValue(JSON.stringify(editableConfig, null, 2));
|
||||||
|
setJsonEditServer(server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveJsonEdit = async () => {
|
||||||
|
if (!jsonEditServer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonEditValue);
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
toast.error('Config must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields based on type
|
||||||
|
const serverType = parsed.type || 'stdio';
|
||||||
|
|
||||||
|
if (!parsed.name || typeof parsed.name !== 'string') {
|
||||||
|
toast.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
if (!parsed.command || typeof parsed.command !== 'string') {
|
||||||
|
toast.error('Command is required for stdio servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (serverType === 'sse' || serverType === 'http') {
|
||||||
|
if (!parsed.url || typeof parsed.url !== 'string') {
|
||||||
|
toast.error('URL is required for SSE/HTTP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData: Partial<MCPServerConfig> = {
|
||||||
|
name: parsed.name,
|
||||||
|
type: serverType,
|
||||||
|
description: parsed.description || undefined,
|
||||||
|
enabled: parsed.enabled !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
updateData.command = parsed.command;
|
||||||
|
updateData.args = Array.isArray(parsed.args) ? parsed.args : undefined;
|
||||||
|
updateData.env =
|
||||||
|
typeof parsed.env === 'object' && !Array.isArray(parsed.env) ? parsed.env : undefined;
|
||||||
|
// Clear HTTP fields
|
||||||
|
updateData.url = undefined;
|
||||||
|
updateData.headers = undefined;
|
||||||
|
} else {
|
||||||
|
updateData.url = parsed.url;
|
||||||
|
updateData.headers =
|
||||||
|
typeof parsed.headers === 'object' && !Array.isArray(parsed.headers)
|
||||||
|
? parsed.headers
|
||||||
|
: undefined;
|
||||||
|
// Clear stdio fields
|
||||||
|
updateData.command = undefined;
|
||||||
|
updateData.args = undefined;
|
||||||
|
updateData.env = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMCPServer(jsonEditServer.id, updateData);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
|
||||||
|
toast.success('Server configuration updated');
|
||||||
|
setJsonEditServer(null);
|
||||||
|
setJsonEditValue('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGlobalJsonEdit = () => {
|
||||||
|
// Build the full mcpServers config object
|
||||||
|
const exportData: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
const serverConfig: Record<string, unknown> = {
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.description) {
|
||||||
|
serverConfig.description = server.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.enabled === false) {
|
||||||
|
serverConfig.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
serverConfig.command = server.command;
|
||||||
|
if (server.args?.length) serverConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
serverConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||||
|
serverConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[server.name] = serverConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
|
||||||
|
setIsGlobalJsonEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGlobalJsonEdit = async () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(globalJsonValue);
|
||||||
|
|
||||||
|
// Support both formats
|
||||||
|
const servers = parsed.mcpServers || parsed;
|
||||||
|
|
||||||
|
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||||
|
toast.error('Invalid format: expected object with server configurations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all servers first
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
if (typeof config !== 'object' || config === null) {
|
||||||
|
toast.error(`Invalid config for "${name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
const serverType = (serverConfig.type as string) || 'stdio';
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
||||||
|
toast.error(`Command is required for "${name}" (stdio)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (serverType === 'sse' || serverType === 'http') {
|
||||||
|
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
||||||
|
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing servers by name for updating
|
||||||
|
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||||
|
const processedNames = new Set<string>();
|
||||||
|
|
||||||
|
// Update or add servers
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name,
|
||||||
|
type: serverType,
|
||||||
|
description: (serverConfig.description as string) || undefined,
|
||||||
|
enabled: serverConfig.enabled !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
serverData.command = serverConfig.command as string;
|
||||||
|
if (Array.isArray(serverConfig.args)) {
|
||||||
|
serverData.args = serverConfig.args as string[];
|
||||||
|
}
|
||||||
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||||
|
serverData.env = serverConfig.env as Record<string, string>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverData.url = serverConfig.url as string;
|
||||||
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||||
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = existingByName.get(name);
|
||||||
|
if (existing) {
|
||||||
|
updateMCPServer(existing.id, serverData);
|
||||||
|
} else {
|
||||||
|
addMCPServer(serverData);
|
||||||
|
}
|
||||||
|
processedNames.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove servers that are no longer in the JSON
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
if (!processedNames.has(server.name)) {
|
||||||
|
removeMCPServer(server.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncSettingsToServer();
|
||||||
|
|
||||||
|
toast.success('MCP servers configuration updated');
|
||||||
|
setIsGlobalJsonEditOpen(false);
|
||||||
|
setGlobalJsonValue('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Store state
|
||||||
|
mcpServers,
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
setMcpAutoApproveTools,
|
||||||
|
setMcpUnrestrictedTools,
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
isAddDialogOpen,
|
||||||
|
setIsAddDialogOpen,
|
||||||
|
editingServer,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
isImportDialogOpen,
|
||||||
|
setIsImportDialogOpen,
|
||||||
|
importJson,
|
||||||
|
setImportJson,
|
||||||
|
jsonEditServer,
|
||||||
|
setJsonEditServer,
|
||||||
|
jsonEditValue,
|
||||||
|
setJsonEditValue,
|
||||||
|
isGlobalJsonEditOpen,
|
||||||
|
setIsGlobalJsonEditOpen,
|
||||||
|
globalJsonValue,
|
||||||
|
setGlobalJsonValue,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isRefreshing,
|
||||||
|
serverTestStates,
|
||||||
|
expandedServers,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalToolsCount,
|
||||||
|
showToolsWarning,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleRefresh,
|
||||||
|
handleTestServer,
|
||||||
|
toggleServerExpanded,
|
||||||
|
handleOpenAddDialog,
|
||||||
|
handleOpenEditDialog,
|
||||||
|
handleCloseDialog,
|
||||||
|
handleSave,
|
||||||
|
handleToggleEnabled,
|
||||||
|
handleDelete,
|
||||||
|
handleImportJson,
|
||||||
|
handleExportJson,
|
||||||
|
handleOpenJsonEdit,
|
||||||
|
handleSaveJsonEdit,
|
||||||
|
handleOpenGlobalJsonEdit,
|
||||||
|
handleSaveGlobalJsonEdit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { MCPServersSection } from './mcp-servers-section';
|
export { MCPServersSection } from './mcp-servers-section';
|
||||||
|
export { MCPToolsList, type MCPToolDisplay } from './mcp-tools-list';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className={cn('text-sm text-destructive break-words', className)}>{error}</div>;
|
return <div className={cn('text-sm text-destructive wrap-break-word', className)}>{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tools || tools.length === 0) {
|
if (!tools || tools.length === 0) {
|
||||||
@@ -89,7 +89,7 @@ export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsLis
|
|||||||
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
|
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
|
||||||
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
|
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
|
||||||
{tool.description && (
|
{tool.description && (
|
||||||
<span className="text-xs text-muted-foreground line-clamp-2 break-words w-full">
|
<span className="text-xs text-muted-foreground line-clamp-2 wrap-break-word w-full">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { MCPToolDisplay } from './mcp-tools-list';
|
||||||
|
|
||||||
|
export type ServerType = 'stdio' | 'sse' | 'http';
|
||||||
|
|
||||||
|
export interface ServerFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: ServerType;
|
||||||
|
command: string;
|
||||||
|
args: string;
|
||||||
|
url: string;
|
||||||
|
headers: string; // JSON string for headers
|
||||||
|
env: string; // JSON string for env vars
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultFormData: ServerFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'stdio',
|
||||||
|
command: '',
|
||||||
|
args: '',
|
||||||
|
url: '',
|
||||||
|
headers: '',
|
||||||
|
env: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ServerTestState {
|
||||||
|
status: 'idle' | 'testing' | 'success' | 'error';
|
||||||
|
tools?: MCPToolDisplay[];
|
||||||
|
error?: string;
|
||||||
|
connectionTime?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import type { ServerType, ServerTestState } from './types';
|
||||||
|
import { SENSITIVE_PARAM_PATTERNS } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive values in URLs (query params with key-like names)
|
||||||
|
*/
|
||||||
|
export function maskSensitiveUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const params = new URLSearchParams(urlObj.search);
|
||||||
|
let hasSensitive = false;
|
||||||
|
|
||||||
|
for (const [key] of params.entries()) {
|
||||||
|
if (SENSITIVE_PARAM_PATTERNS.some((pattern) => pattern.test(key))) {
|
||||||
|
params.set(key, '***');
|
||||||
|
hasSensitive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSensitive) {
|
||||||
|
urlObj.search = params.toString();
|
||||||
|
return urlObj.toString();
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, try simple regex replacement for common patterns
|
||||||
|
return url.replace(
|
||||||
|
/([?&])(api[-_]?key|auth|token|secret|password|credential)=([^&]*)/gi,
|
||||||
|
'$1$2=***'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerIcon(type: ServerType = 'stdio') {
|
||||||
|
if (type === 'stdio') return Terminal;
|
||||||
|
return Globe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestStatusIcon(status: ServerTestState['status']) {
|
||||||
|
switch (status) {
|
||||||
|
case 'testing':
|
||||||
|
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user