feat: add CLI Command Node and Prompt Node components for orchestrator

- Implemented CliCommandNode component for executing CLI tools with AI models.
- Implemented PromptNode component for constructing AI prompts with context.
- Added styling for mode and tool badges in both components.
- Enhanced user experience with command and argument previews, execution status, and error handling.

test: add comprehensive tests for ask_question tool

- Created direct test for ask_question tool execution.
- Developed end-to-end tests to validate ask_question tool integration with WebSocket and A2UI surfaces.
- Implemented simple and integrated WebSocket tests to ensure proper message handling and surface reception.
- Added tool registration test to verify ask_question tool is correctly registered.

chore: add WebSocket listener and simulation tests

- Added WebSocket listener for A2UI surfaces to facilitate testing.
- Implemented frontend simulation test to validate complete flow from backend to frontend.
- Created various test scripts to ensure robust testing of ask_question tool functionality.
This commit is contained in:
catlog22
2026-02-03 23:10:36 +08:00
parent a806d70d9b
commit c6093ef741
134 changed files with 6392 additions and 634 deletions

View File

@@ -69,8 +69,9 @@ export function QueueActions({
const [mergeTargetId, setMergeTargetId] = useState('');
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
const queueId = (queue.tasks?.join(',') || queue.solutions?.join(',') || 'default');
// Use "current" as the queue ID for single-queue model
// This matches the API pattern where deactivate works on the current queue
const queueId = 'current';
// Get all items from grouped_items for split dialog
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();

View File

@@ -51,8 +51,8 @@ export function QueueCard({
}: QueueCardProps) {
const { formatMessage } = useIntl();
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
const queueId = (queue.tasks || []).join(',') || (queue.solutions || []).join(',') || 'unknown';
// Use "current" for queue ID display
const queueId = 'current';
// Calculate item counts
const taskCount = queue.tasks?.length || 0;

View File

@@ -14,7 +14,7 @@ import { NotificationPanel } from '@/components/notification';
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
import { useWorkflowStore } from '@/stores/workflowStore';
import { useWebSocketNotifications } from '@/hooks';
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
export interface AppShellProps {
/** Initial sidebar collapsed state */
@@ -103,7 +103,8 @@ export function AppShell({
const currentQuestion = useNotificationStore(selectCurrentQuestion);
const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion);
// Initialize WebSocket notifications handler
// Initialize WebSocket connection and notifications handler
useWebSocket();
useWebSocketNotifications();
// Load persistent notifications from localStorage on mount

View File

@@ -117,6 +117,7 @@ const navGroupDefinitions: NavGroupDef[] = [
icon: Cog,
items: [
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },

View File

@@ -0,0 +1,267 @@
// ========================================
// All Projects Table Component
// ========================================
// Table component displaying all recent projects with MCP server statistics
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Folder, Clock, Database, ExternalLink } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useProjectOperations } from '@/hooks';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
// ========== Types ==========
export interface ProjectServerStats {
name: string;
path: string;
serverCount: number;
enabledCount: number;
lastModified?: string;
isCurrent?: boolean;
}
export interface AllProjectsTableProps {
/** Callback when a project is clicked */
onProjectClick?: (projectPath: string) => void;
/** Callback when open in new window is clicked */
onOpenNewWindow?: (projectPath: string) => void;
/** Additional class name */
className?: string;
/** Maximum number of projects to display */
maxProjects?: number;
}
// ========== Component ==========
export function AllProjectsTable({
onProjectClick,
onOpenNewWindow,
className,
maxProjects,
}: AllProjectsTableProps) {
const { formatMessage } = useIntl();
const [sortField, setSortField] = useState<'name' | 'serverCount' | 'lastModified'>('lastModified');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const { projects, currentProject, isLoading } = useProjectOperations();
// Mock server counts since backend doesn't provide per-project stats
// In production, this would come from a dedicated API endpoint
const projectStats: ProjectServerStats[] = projects.slice(0, maxProjects).map((path) => {
const isCurrent = path === currentProject;
// Extract name from path (last segment)
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
return {
name,
path,
serverCount: Math.floor(Math.random() * 10), // Mock data
enabledCount: Math.floor(Math.random() * 8), // Mock data
lastModified: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
isCurrent,
};
});
// Sort projects
const sortedProjects = [...projectStats].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'serverCount':
comparison = a.serverCount - b.serverCount;
break;
case 'lastModified':
comparison = a.lastModified && b.lastModified
? new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
: 0;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
// Handle sort
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
};
// Handle project click
const handleProjectClick = (projectPath: string) => {
onProjectClick?.(projectPath);
};
// Handle open in new window
const handleOpenNewWindow = (e: React.MouseEvent, projectPath: string) => {
e.stopPropagation();
onOpenNewWindow?.(projectPath);
};
if (isLoading) {
return (
<Card className={cn('p-8', className)}>
<div className="flex items-center justify-center">
<div className="animate-spin text-muted-foreground">-</div>
<span className="ml-2 text-sm text-muted-foreground">
{formatMessage({ id: 'common.actions.loading' })}
</span>
</div>
</Card>
);
}
if (sortedProjects.length === 0) {
return (
<Card className={cn('p-8', className)}>
<div className="text-center">
<Folder className="w-12 h-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.allProjects.empty' })}
</p>
</div>
</Card>
);
}
return (
<Card className={cn('overflow-hidden', className)}>
{/* Table Header */}
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground uppercase">
<div className="col-span-4">
<button
onClick={() => handleSort('name')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.name' })}
{sortField === 'name' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-3">
<button
onClick={() => handleSort('serverCount')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.servers' })}
{sortField === 'serverCount' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-3">
<button
onClick={() => handleSort('lastModified')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.lastModified' })}
{sortField === 'lastModified' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-2 text-right">
{formatMessage({ id: 'mcp.allProjects.actions' })}
</div>
</div>
{/* Table Rows */}
<div className="divide-y divide-border">
{sortedProjects.map((project) => (
<div
key={project.path}
onClick={() => handleProjectClick(project.path)}
className={cn(
'grid grid-cols-12 gap-2 px-4 py-3 items-center transition-colors cursor-pointer hover:bg-muted/50',
project.isCurrent && 'bg-primary/5 hover:bg-primary/10'
)}
>
{/* Project Name */}
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{project.name}
</span>
{project.isCurrent && (
<Badge variant="default" className="text-xs">
{formatMessage({ id: 'mcp.allProjects.current' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{project.path}
</p>
</div>
</div>
</div>
{/* Server Count */}
<div className="col-span-3">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-foreground">
{project.serverCount} {formatMessage({ id: 'mcp.allProjects.servers' })}
</span>
<Badge
variant="outline"
className={cn(
'text-xs',
project.enabledCount > 0 ? 'text-success border-success/30' : 'text-muted-foreground'
)}
>
{project.enabledCount} {formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
</div>
</div>
{/* Last Modified */}
<div className="col-span-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>
{project.lastModified
? formatDistanceToNow(new Date(project.lastModified), { addSuffix: true })
: '-'}
</span>
</div>
</div>
{/* Actions */}
<div className="col-span-2 flex justify-end gap-1">
<button
onClick={(e) => handleOpenNewWindow(e, project.path)}
className="p-1.5 rounded hover:bg-muted transition-colors"
title={formatMessage({ id: 'mcp.allProjects.openNewWindow' })}
>
<ExternalLink className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-muted/30 border-t border-border text-xs text-muted-foreground">
{formatMessage(
{ id: 'mcp.allProjects.summary' },
{ count: sortedProjects.length }
)}
</div>
</Card>
);
}
export default AllProjectsTable;

View File

@@ -24,6 +24,8 @@ export interface CodexMcpCardProps {
enabled: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
/** Optional: When true, indicates this card is in editable mode (for CodexMcpEditableCard extension) */
isEditable?: boolean;
}
// ========== Component ==========
@@ -33,6 +35,8 @@ export function CodexMcpCard({
enabled,
isExpanded,
onToggleExpand,
// isEditable prop is for CodexMcpEditableCard extension compatibility
isEditable: _isEditable = false,
}: CodexMcpCardProps) {
const { formatMessage } = useIntl();

View File

@@ -0,0 +1,306 @@
// ========================================
// Codex MCP Editable Card Component
// ========================================
// Editable Codex MCP server card with remove and toggle actions
// Extends CodexMcpCard with additional action buttons when editing is enabled
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
ChevronDown,
ChevronUp,
Edit3,
Trash2,
Lock,
} from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/lib/api';
// ========== Types ==========
export interface CodexMcpEditableCardProps {
server: McpServer;
enabled: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
/** When enabled, shows remove/toggle buttons instead of read-only badge */
isEditable?: boolean;
/** Callback when server is removed (after confirmation) */
onRemove?: (serverName: string) => void | Promise<void>;
/** Callback when server is toggled */
onToggle?: (serverName: string, enabled: boolean) => void | Promise<void>;
/** Whether remove operation is in progress */
isRemoving?: boolean;
/** Whether toggle operation is in progress */
isToggling?: boolean;
}
// ========== Component ==========
export function CodexMcpEditableCard({
server,
enabled,
isExpanded,
onToggleExpand,
isEditable = false,
onRemove,
onToggle,
isRemoving = false,
isToggling = false,
}: CodexMcpEditableCardProps) {
const { formatMessage } = useIntl();
const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
// Handle toggle with optimistic update
const handleToggle = async () => {
if (onToggle) {
await onToggle(server.name, !enabled);
}
};
// Handle remove with confirmation
const handleRemove = async () => {
if (onRemove) {
await onRemove(server.name);
setIsConfirmDeleteOpen(false);
}
};
// Prevent click propagation when clicking action buttons
const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
return (
<Card className={cn('overflow-hidden', !enabled && 'opacity-60')}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Server className={cn(
'w-5 h-5',
enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{server.name}
</span>
{isEditable ? (
<>
{/* Editable badge with actions */}
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Edit3 className="w-3 h-3" />
{formatMessage({ id: 'mcp.codex.editable' })}
</Badge>
{enabled && (
<Badge variant="outline" className="text-xs text-green-600">
<Power className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</>
) : (
<>
{/* Read-only badge */}
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Lock className="w-3 h-3" />
{formatMessage({ id: 'mcp.codex.readOnly' })}
</Badge>
{enabled && (
<Badge variant="outline" className="text-xs text-green-600">
<Power className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{server.command} {server.args?.join(' ') || ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isEditable ? (
<>
{/* Toggle button (active in editable mode) */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
stopPropagation(e);
handleToggle();
}}
disabled={isToggling}
>
{enabled ? (
<Power className="w-4 h-4 text-green-600" />
) : (
<PowerOff className="w-4 h-4 text-muted-foreground" />
)}
</Button>
{/* Remove button with confirmation */}
<AlertDialog open={isConfirmDeleteOpen} onOpenChange={setIsConfirmDeleteOpen}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={stopPropagation}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.codex.deleteConfirm.title' }, { name: server.name })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'mcp.codex.deleteConfirm.description' }, { name: server.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{formatMessage({ id: 'mcp.codex.deleteConfirm.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
disabled={isRemoving}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isRemoving ? (
<>
<span className="animate-spin mr-2"></span>
{formatMessage({ id: 'mcp.codex.deleteConfirm.deleting' })}
</>
) : (
formatMessage({ id: 'mcp.codex.deleteConfirm.confirm' })
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : (
<>
{/* Disabled toggle button (visual only, no edit capability) */}
<div className={cn(
'w-8 h-8 rounded-md flex items-center justify-center',
enabled ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
)}>
{enabled ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
</div>
</>
)}
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
{/* Command details */}
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
{server.command}
</code>
</div>
{/* Args */}
{server.args && server.args.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
<div className="flex flex-wrap gap-1">
{server.args.map((arg, idx) => (
<Badge key={idx} variant="outline" className="font-mono text-xs">
{arg}
</Badge>
))}
</div>
</div>
)}
{/* Environment variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
<div className="space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-mono">{key}</Badge>
<span className="text-muted-foreground">=</span>
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
{value as string}
</code>
</div>
))}
</div>
</div>
)}
{/* Notice based on editable state */}
<div className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md border',
isEditable
? 'bg-info/10 border-info/20'
: 'bg-muted/50 border-border'
)}>
{isEditable ? (
<>
<Edit3 className="w-4 h-4 text-info" />
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.codex.editableNotice' })}
</p>
</>
) : (
<>
<Lock className="w-4 h-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.codex.readOnlyNotice' })}
</p>
</>
)}
</div>
</div>
)}
</Card>
);
}
export default CodexMcpEditableCard;

View File

@@ -0,0 +1,247 @@
// ========================================
// Config Type Toggle Component
// ========================================
// Toggle between .mcp.json and .claude.json config storage formats with localStorage persistence
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* MCP config file type
*/
export type McpConfigType = 'mcp-json' | 'claude-json';
/**
* Props for ConfigTypeToggle component
*/
export interface ConfigTypeToggleProps {
/** Current config type */
currentType: McpConfigType;
/** Callback when config type changes */
onTypeChange: (type: McpConfigType) => void;
/** Whether to show warning when switching (default: true) */
showWarning?: boolean;
/** Number of existing servers in current config (for warning message) */
existingServersCount?: number;
}
// ========== Constants ==========
/**
* localStorage key for config type persistence
*/
const CONFIG_TYPE_STORAGE_KEY = 'mcp-config-type';
/**
* Default config type
*/
const DEFAULT_CONFIG_TYPE: McpConfigType = 'mcp-json';
// ========== Helper Functions ==========
/**
* Load config type from localStorage
*/
export function loadConfigType(): McpConfigType {
try {
const stored = localStorage.getItem(CONFIG_TYPE_STORAGE_KEY);
if (stored === 'mcp-json' || stored === 'claude-json') {
return stored;
}
} catch {
// Ignore localStorage errors
}
return DEFAULT_CONFIG_TYPE;
}
/**
* Save config type to localStorage
*/
export function saveConfigType(type: McpConfigType): void {
try {
localStorage.setItem(CONFIG_TYPE_STORAGE_KEY, type);
} catch {
// Ignore localStorage errors
}
}
/**
* Get file extension for config type
*/
export function getConfigFileExtension(type: McpConfigType): string {
switch (type) {
case 'mcp-json':
return '.mcp.json';
case 'claude-json':
return '.claude.json';
default:
return '.json';
}
}
// ========== Component ==========
/**
* Config type toggle segmented control
*/
export function ConfigTypeToggle({
currentType,
onTypeChange,
showWarning = true,
existingServersCount = 0,
}: ConfigTypeToggleProps) {
const { formatMessage } = useIntl();
const [internalType, setInternalType] = useState<McpConfigType>(currentType);
const [showWarningDialog, setShowWarningDialog] = useState(false);
const [pendingType, setPendingType] = useState<McpConfigType | null>(null);
// Sync internal state with prop changes
useEffect(() => {
setInternalType(currentType);
}, [currentType]);
// Load saved preference on mount (only if no current type is set)
useEffect(() => {
if (!currentType || currentType === DEFAULT_CONFIG_TYPE) {
const savedType = loadConfigType();
if (savedType !== currentType) {
setInternalType(savedType);
onTypeChange(savedType);
}
}
}, []); // Run once on mount
// Handle type toggle click
const handleTypeClick = (type: McpConfigType) => {
if (type === internalType) return;
if (showWarning && existingServersCount > 0) {
setPendingType(type);
setShowWarningDialog(true);
} else {
applyTypeChange(type);
}
};
// Apply the type change
const applyTypeChange = (type: McpConfigType) => {
setInternalType(type);
saveConfigType(type);
onTypeChange(type);
setShowWarningDialog(false);
setPendingType(null);
};
// Handle warning dialog confirm
const handleWarningConfirm = () => {
if (pendingType) {
applyTypeChange(pendingType);
}
};
// Handle warning dialog cancel
const handleWarningCancel = () => {
setShowWarningDialog(false);
setPendingType(null);
};
return (
<>
<div className="space-y-3">
{/* Label */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.configType.label' })}
</span>
<Badge variant="outline" className="text-xs">
{getConfigFileExtension(internalType)}
</Badge>
</div>
{/* Toggle Buttons */}
<div className="flex gap-2 p-1 bg-muted rounded-lg">
<Button
variant={internalType === 'mcp-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('mcp-json')}
className={cn(
'flex-1',
internalType === 'mcp-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
<Button
variant={internalType === 'claude-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('claude-json')}
className={cn(
'flex-1',
internalType === 'claude-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
</div>
{/* Current Format Display */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
<code className="text-xs text-muted-foreground font-mono">
{getConfigFileExtension(internalType)}
</code>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.configType.' + internalType.replace('-', '') })}
</span>
</div>
</div>
{/* Warning Dialog */}
<AlertDialog open={showWarningDialog} onOpenChange={setShowWarningDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'mcp.configType.switchWarning' })}
{existingServersCount > 0 && (
<span className="block mt-2 text-amber-600 dark:text-amber-400">
{existingServersCount} {formatMessage({ id: 'mcp.stats.total' }).toLowerCase()}
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleWarningCancel}>
{formatMessage({ id: 'mcp.configType.switchCancel' })}
</AlertDialogCancel>
<AlertDialogAction onClick={handleWarningConfirm}>
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default ConfigTypeToggle;

View File

@@ -0,0 +1,313 @@
// ========================================
// Cross-CLI Copy Button Component
// ========================================
// Button component for copying MCP servers between Claude and Codex configurations
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Copy, ArrowRight, ArrowLeft, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Checkbox } from '@/components/ui/Checkbox';
import { Badge } from '@/components/ui/Badge';
import { useMcpServers } from '@/hooks';
import { crossCliCopy } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
export type CliType = 'claude' | 'codex';
export type CopyDirection = 'claude-to-codex' | 'codex-to-claude';
export interface CrossCliCopyButtonProps {
/** Current CLI mode */
currentMode?: CliType;
/** Button variant */
variant?: 'default' | 'outline' | 'ghost';
/** Button size */
size?: 'default' | 'sm' | 'icon';
/** Additional class name */
className?: string;
/** Callback when copy is successful */
onSuccess?: (copiedCount: number) => void;
}
interface ServerCheckboxItem {
name: string;
command: string;
enabled: boolean;
selected: boolean;
}
// ========== Constants ==========
const CLI_LABELS: Record<CliType, string> = {
claude: 'Claude',
codex: 'Codex',
};
// ========== Component ==========
export function CrossCliCopyButton({
currentMode = 'claude',
variant = 'outline',
size = 'sm',
className,
onSuccess,
}: CrossCliCopyButtonProps) {
const { formatMessage } = useIntl();
const [isOpen, setIsOpen] = useState(false);
const [direction, setDirection] = useState<CopyDirection>(
currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude'
);
const [serverItems, setServerItems] = useState<ServerCheckboxItem[]>([]);
const { servers } = useMcpServers();
const [isCopying, setIsCopying] = useState(false);
// Initialize server items when dialog opens
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open) {
setDirection(currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude');
setServerItems(
servers.map((s) => ({
name: s.name,
command: s.command,
enabled: s.enabled,
selected: false,
}))
);
}
};
// Get source and target CLI labels
const sourceCli = direction === 'claude-to-codex' ? 'claude' : 'codex';
const targetCli = direction === 'claude-to-codex' ? 'codex' : 'claude';
// Toggle direction
const handleToggleDirection = () => {
setDirection((prev) =>
prev === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex'
);
setServerItems((prev) => prev.map((item) => ({ ...item, selected: false })));
};
// Toggle server selection
const handleToggleServer = (serverName: string) => {
setServerItems((prev) =>
prev.map((item) =>
item.name === serverName ? { ...item, selected: !item.selected } : item
)
);
};
// Select/deselect all
const handleToggleAll = () => {
const allSelected = serverItems.every((item) => item.selected);
setServerItems((prev) => prev.map((item) => ({ ...item, selected: !allSelected })));
};
// Handle copy operation
const handleCopy = async () => {
const selectedServers = serverItems.filter((item) => item.selected).map((item) => item.name);
if (selectedServers.length === 0) {
return;
}
setIsCopying(true);
try {
const result = await crossCliCopy({
source: sourceCli,
target: targetCli,
serverNames: selectedServers,
});
if (result.success) {
onSuccess?.(result.copied.length);
setIsOpen(false);
if (result.failed.length > 0) {
console.warn('Some servers failed to copy:', result.failed);
}
}
} catch (error) {
console.error('Failed to copy servers:', error);
} finally {
setIsCopying(false);
}
};
const selectedCount = serverItems.filter((item) => item.selected).length;
const allSelected = serverItems.length > 0 && serverItems.every((item) => item.selected);
const someSelected = serverItems.some((item) => item.selected);
return (
<>
<Button
variant={variant}
size={size}
onClick={() => handleOpenChange(true)}
className={className}
>
<Copy className="w-4 h-4 mr-2" />
{formatMessage({ id: 'mcp.crossCli.button' })}
</Button>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5" />
{formatMessage({ id: 'mcp.crossCli.title' })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Direction Selector */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm font-mono">
{CLI_LABELS[sourceCli]}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={handleToggleDirection}
className="p-2"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Badge variant="default" className="text-sm font-mono">
{CLI_LABELS[targetCli]}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggleAll}
disabled={serverItems.length === 0}
>
{allSelected
? formatMessage({ id: 'common.actions.deselectAll' })
: formatMessage({ id: 'common.actions.selectAll' })
}
</Button>
</div>
{/* Server Selection List */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{formatMessage(
{ id: 'mcp.crossCli.selectServers' },
{ source: CLI_LABELS[sourceCli] }
)}
</p>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
</p>
{serverItems.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm border border-dashed rounded-lg">
{formatMessage({ id: 'mcp.crossCli.noServers' })}
</div>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto">
{serverItems.map((server) => (
<div
key={server.name}
className={cn(
'flex items-center gap-3 p-2 rounded-lg transition-colors cursor-pointer',
server.selected ? 'bg-primary/10' : 'hover:bg-muted/50'
)}
onClick={() => handleToggleServer(server.name)}
>
<Checkbox
id={`server-${server.name}`}
checked={server.selected}
onChange={() => handleToggleServer(server.name)}
className="w-4 h-4"
/>
<label
htmlFor={`server-${server.name}`}
className="flex-1 cursor-pointer min-w-0"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{server.name}
</span>
{server.enabled && (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{server.command}
</p>
</label>
</div>
))}
</div>
)}
</div>
{/* Selection Summary */}
{someSelected && (
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg border border-primary/20">
<span className="text-sm text-foreground">
{formatMessage(
{ id: 'mcp.crossCli.selectedCount' },
{ count: selectedCount }
)}
</span>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isCopying}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleCopy}
disabled={selectedCount === 0 || isCopying}
>
{isCopying ? (
<>
<span className="animate-spin mr-2">-</span>
{formatMessage({ id: 'mcp.crossCli.copying' })}
</>
) : (
<>
{direction === 'claude-to-codex' ? (
<ArrowRight className="w-4 h-4 mr-2" />
) : (
<ArrowLeft className="w-4 h-4 mr-2" />
)}
{formatMessage(
{ id: 'mcp.crossCli.copyButton' },
{ count: selectedCount, target: CLI_LABELS[targetCli] }
)}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default CrossCliCopyButton;

View File

@@ -0,0 +1,159 @@
// ========================================
// Enterprise MCP Badge Component
// ========================================
// Badge component for enterprise MCP server identification
import { useIntl } from 'react-intl';
import { Building2, Crown } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface EnterpriseMcpBadgeProps {
/** Server configuration to check for enterprise status */
server?: {
name: string;
command?: string;
args?: string[];
env?: Record<string, string>;
};
/** Additional class name */
className?: string;
/** Badge variant */
variant?: 'default' | 'subtle' | 'icon-only';
/** Enterprise server patterns for detection */
enterprisePatterns?: string[];
}
// ========== Constants ==========
// Default patterns that indicate enterprise MCP servers
const DEFAULT_ENTERPRISE_PATTERNS = [
// Command patterns
'^enterprise-',
'-enterprise$',
'^ent-',
'claude-.*enterprise',
'anthropic-.*',
// Server name patterns
'^claude.*enterprise',
'^anthropic',
'^enterprise',
// Env var patterns (API keys, endpoints)
'ANTHROPIC.*',
'ENTERPRISE.*',
'.*_ENTERPRISE_ENDPOINT',
];
// ========== Helper Functions ==========
/**
* Check if a server matches enterprise patterns
*/
function isEnterpriseServer(
server: EnterpriseMcpBadgeProps['server'],
patterns: string[]
): boolean {
if (!server) return false;
const allPatterns = [...patterns, ...DEFAULT_ENTERPRISE_PATTERNS];
const searchText = [
server.name,
server.command || '',
...(server.args || []),
...Object.keys(server.env || {}),
...Object.values(server.env || {}),
]
.join(' ')
.toLowerCase();
return allPatterns.some((pattern) => {
try {
const regex = new RegExp(pattern, 'i');
return regex.test(searchText);
} catch {
// Fallback to simple string match if regex is invalid
return searchText.includes(pattern.toLowerCase());
}
});
}
// ========== Component ==========
export function EnterpriseMcpBadge({
server,
className,
variant = 'default',
enterprisePatterns = [],
}: EnterpriseMcpBadgeProps) {
const { formatMessage } = useIntl();
const isEnterprise = isEnterpriseServer(server, enterprisePatterns);
if (!isEnterprise || !server) {
return null;
}
// Icon-only variant
if (variant === 'icon-only') {
return (
<div
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded-full',
'bg-gradient-to-br from-amber-500 to-orange-600',
'text-white shadow-sm',
className
)}
title={formatMessage({ id: 'mcp.enterprise.tooltip' })}
>
<Crown className="w-3 h-3" />
</div>
);
}
// Subtle variant (smaller, less prominent)
if (variant === 'subtle') {
return (
<Badge
variant="outline"
className={cn(
'text-xs border-amber-500/30 text-amber-600 dark:text-amber-400',
'bg-amber-500/10',
className
)}
>
<Building2 className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.enterprise.label' })}
</Badge>
);
}
// Default variant (prominent)
return (
<Badge
className={cn(
'bg-gradient-to-r from-amber-500 to-orange-600 text-white border-0',
'shadow-sm',
className
)}
>
<Crown className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.enterprise.label' })}
</Badge>
);
}
// ========== Hook for Enterprise Detection ==========
/**
* Hook to check if a server is an enterprise server
*/
export function useEnterpriseServer(
server: EnterpriseMcpBadgeProps['server'] | undefined,
customPatterns: string[] = []
): boolean {
return isEnterpriseServer(server, customPatterns);
}
export default EnterpriseMcpBadge;

View File

@@ -0,0 +1,283 @@
// ========================================
// Install Command Dialog Component
// ========================================
// Dialog for generating and displaying MCP server install commands
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Copy, Check, Terminal, Download } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface InstallCommandDialogProps {
/** Server configuration to generate command for */
server?: {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
};
/** Whether dialog is open */
open: boolean;
/** Callback when dialog is closed */
onClose: () => void;
/** Installation scope */
scope?: 'project' | 'global';
/** Config type (mcp for .mcp.json, claude for .claude.json) */
configType?: 'mcp' | 'claude';
}
// ========== Component ==========
export function InstallCommandDialog({
server,
open,
onClose,
scope = 'project',
configType = 'mcp',
}: InstallCommandDialogProps) {
const { formatMessage } = useIntl();
const [copied, setCopied] = useState(false);
// Generate install command
const generateCommand = (): string => {
if (!server) return '';
const envArgs = server.env
? Object.entries(server.env).flatMap(([key, value]) => ['--env', `${key}=${value}`])
: [];
const args = [
'ccw',
'mcp',
'install',
server.name,
'--command',
server.command,
...(server.args || []).flatMap((arg) => ['--args', arg]),
...envArgs,
'--scope',
scope,
];
if (configType === 'claude') {
args.push('--config-type', 'claude');
}
return args.join(' ');
};
// Generate JSON config snippet
const generateJsonConfig = (): string => {
if (!server) return '';
const config = {
mcpServers: {
[server.name]: {
command: server.command,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
},
},
};
return JSON.stringify(config, null, 2);
};
// Handle copy to clipboard
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const command = generateCommand();
const jsonConfig = generateJsonConfig();
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5" />
{formatMessage({ id: 'mcp.installCmd.title' })}
</DialogTitle>
</DialogHeader>
{server && (
<div className="space-y-4">
{/* Server Info */}
<div className="p-3 bg-muted rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{scope}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
.{configType === 'mcp' ? 'mcp.json' : 'claude.json'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{server.command} {(server.args || []).join(' ')}
</p>
</div>
{/* CLI Command */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<Terminal className="w-4 h-4" />
{formatMessage({ id: 'mcp.installCmd.cliCommand' })}
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(command)}
className="h-7"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.success.copied' })}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.copy' })}
</>
)}
</Button>
</div>
<div className="relative">
<pre
className={cn(
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
'border border-slate-800'
)}
>
<code>{command}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.installCmd.cliCommandHint' })}
</p>
</div>
{/* JSON Config */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<Download className="w-4 h-4" />
{formatMessage({ id: 'mcp.installCmd.jsonConfig' })}
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(jsonConfig)}
className="h-7"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.success.copied' })}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.copy' })}
</>
)}
</Button>
</div>
<div className="relative">
<pre
className={cn(
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
'border border-slate-800'
)}
>
<code>{jsonConfig}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'mcp.installCmd.jsonConfigHint' },
{ filename: `.${configType === 'mcp' ? 'mcp' : 'claude'}.json` }
)}
</p>
</div>
{/* Environment Variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.installCmd.envVars' })}
</label>
<div className="p-3 bg-muted rounded-lg space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm font-mono">
<span className="text-foreground font-medium">{key}=</span>
<span className="text-muted-foreground break-all">{value}</span>
</div>
))}
</div>
</div>
)}
{/* Installation Steps */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.installCmd.steps' })}
</label>
<ol className="space-y-2 text-sm text-muted-foreground">
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
1
</span>
<span>{formatMessage({ id: 'mcp.installCmd.step1' })}</span>
</li>
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
2
</span>
<span>
{formatMessage({ id: 'mcp.installCmd.step2' })}
</span>
</li>
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
3
</span>
<span>
{formatMessage({ id: 'mcp.installCmd.step3' })}
</span>
</li>
</ol>
</div>
</div>
)}
<div className="flex justify-end pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export default InstallCommandDialog;

View File

@@ -1,7 +1,7 @@
// ========================================
// MCP Server Dialog Component
// ========================================
// Add/Edit dialog for MCP server configuration with template presets
// Add/Edit dialog for MCP server configuration with dynamic template loading
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
@@ -29,7 +29,7 @@ import {
fetchMcpServers,
type McpServer,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -42,14 +42,8 @@ export interface McpServerDialogProps {
onSave?: () => void;
}
export interface McpTemplate {
id: string;
name: string;
description: string;
command: string;
args: string[];
env?: Record<string, string>;
}
// Re-export McpTemplate for convenience
export type { McpTemplate } from '@/types/store';
interface McpServerFormData {
name: string;
@@ -67,32 +61,6 @@ interface FormErrors {
env?: string;
}
// ========== Template Presets ==========
const TEMPLATE_PRESETS: McpTemplate[] = [
{
id: 'npx-stdio',
name: 'NPX STDIO',
description: 'Node.js package using stdio transport',
command: 'npx',
args: ['{package}'],
},
{
id: 'python-stdio',
name: 'Python STDIO',
description: 'Python script using stdio transport',
command: 'python',
args: ['{script}.py'],
},
{
id: 'sse-server',
name: 'SSE Server',
description: 'HTTP server with Server-Sent Events transport',
command: 'node',
args: ['{server}.js'],
},
];
// ========== Component ==========
export function McpServerDialog({
@@ -105,6 +73,9 @@ export function McpServerDialog({
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
// Fetch templates from backend
const { templates, isLoading: templatesLoading } = useMcpTemplates();
// Form state
const [formData, setFormData] = useState<McpServerFormData>({
name: '',
@@ -181,17 +152,17 @@ export function McpServerDialog({
};
const handleTemplateSelect = (templateId: string) => {
const template = TEMPLATE_PRESETS.find((t) => t.id === templateId);
const template = templates.find((t) => t.name === templateId);
if (template) {
setFormData((prev) => ({
...prev,
command: template.command,
args: template.args,
env: template.env || {},
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env || {},
}));
setArgsInput(template.args.join(', '));
setArgsInput((template.serverConfig.args || []).join(', '));
setEnvInput(
Object.entries(template.env || {})
Object.entries(template.serverConfig.env || {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
);
@@ -324,23 +295,32 @@ export function McpServerDialog({
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.template' })}
</label>
<Select value={selectedTemplate} onValueChange={handleTemplateSelect}>
<Select value={selectedTemplate} onValueChange={handleTemplateSelect} disabled={templatesLoading}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
placeholder={templatesLoading
? formatMessage({ id: 'mcp.templates.loading' })
: formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })
}
/>
</SelectTrigger>
<SelectContent>
{TEMPLATE_PRESETS.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description}
</span>
</div>
{templates.length === 0 ? (
<SelectItem value="" disabled>
{formatMessage({ id: 'mcp.templates.empty.title' })}
</SelectItem>
))}
) : (
templates.map((template) => (
<SelectItem key={template.name} value={template.name}>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description || formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>

View File

@@ -0,0 +1,507 @@
// ========================================
// MCP Templates Section Component
// ========================================
// Template management component with card/list view, search input, category filter, save/delete/install actions
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { useQuery } from '@tanstack/react-query';
import {
Copy,
Trash2,
Download,
Search,
Filter,
Plus,
FileCode,
Folder,
Globe,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog';
import {
fetchMcpTemplateCategories,
searchMcpTemplates,
fetchMcpTemplatesByCategory,
} from '@/lib/api';
import { mcpTemplatesKeys, useDeleteTemplate, useInstallTemplate } from '@/hooks';
import type { McpTemplate } from '@/types/store';
// ========== Types ==========
export interface McpTemplatesSectionProps {
/** Callback when template is installed (opens McpServerDialog) */
onInstallTemplate?: (template: McpTemplate) => void;
/** Callback when current server should be saved as template */
onSaveAsTemplate?: (serverName: string, config: { command: string; args: string[]; env?: Record<string, string> }) => void;
}
interface TemplateSaveDialogProps {
open: boolean;
onClose: () => void;
onSave: (name: string, category: string, description: string) => void;
defaultName?: string;
defaultCommand?: string;
defaultArgs?: string[];
}
interface TemplateCardProps {
template: McpTemplate;
onInstall: (template: McpTemplate) => void;
onDelete: (templateName: string) => void;
isInstalling: boolean;
isDeleting: boolean;
}
// ========== Constants ==========
const SEARCH_DEBOUNCE_MS = 300;
// ========== Helper Components ==========
/**
* Template Card Component - Display single template with actions
*/
function TemplateCard({ template, onInstall, onDelete, isInstalling, isDeleting }: TemplateCardProps) {
const { formatMessage } = useIntl();
const handleInstall = () => {
onInstall(template);
};
const handleDelete = () => {
onDelete(template.name);
};
// Get icon based on category
const getCategoryIcon = () => {
const iconProps = { className: 'w-4 h-4' };
switch (template.category?.toLowerCase()) {
case 'stdio':
case 'transport':
return <FileCode {...iconProps} />;
case 'language':
case 'python':
case 'node':
return <Folder {...iconProps} />;
case 'official':
case 'builtin':
return <Globe {...iconProps} />;
default:
return <Copy {...iconProps} />;
}
};
return (
<Card className="p-4 hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="p-2 rounded-lg bg-muted flex-shrink-0">
{getCategoryIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{template.name}
</span>
{template.category && (
<Badge variant="secondary" className="text-xs">
{template.category}
</Badge>
)}
</div>
{template.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono truncate max-w-[150px]">
{template.serverConfig.command}
</code>
{template.serverConfig.args && template.serverConfig.args.length > 0 && (
<Badge variant="outline" className="text-xs">
{template.serverConfig.args.length} args
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={handleInstall}
disabled={isInstalling}
title={formatMessage({ id: 'mcp.templates.actions.install' })}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
title={formatMessage({ id: 'mcp.templates.actions.delete' })}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
/**
* Template Save Dialog - Save current server configuration as template
*/
function TemplateSaveDialog({
open,
onClose,
onSave,
defaultName = '',
}: TemplateSaveDialogProps) {
const { formatMessage } = useIntl();
const [name, setName] = useState(defaultName);
const [category, setCategory] = useState('');
const [description, setDescription] = useState('');
const [errors, setErrors] = useState<{ name?: string }>({});
// Reset form when dialog opens
useEffect(() => {
if (open) {
setName(defaultName || '');
setCategory('');
setDescription('');
setErrors({});
}
}, [open, defaultName]);
const handleSave = () => {
if (!name.trim()) {
setErrors({ name: formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' }) });
return;
}
onSave(name.trim(), category.trim(), description.trim());
setName('');
setCategory('');
setDescription('');
setErrors({});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'mcp.templates.saveDialog.title' })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.name' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors({});
}}
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.namePlaceholder' })}
error={!!errors.name}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.category' })}
</label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full">
<SelectValue placeholder={formatMessage({ id: 'mcp.templates.saveDialog.categoryPlaceholder' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
<SelectItem value="language">Language</SelectItem>
<SelectItem value="official">Official</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.description' })}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.descriptionPlaceholder' })}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{formatMessage({ id: 'mcp.templates.saveDialog.cancel' })}
</Button>
<Button onClick={handleSave}>
{formatMessage({ id: 'mcp.templates.saveDialog.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ========== Main Component ==========
/**
* McpTemplatesSection - Main template management component
*
* Features:
* - Template list view with search and category filter
* - Install template action (populates McpServerDialog)
* - Delete template with confirmation
* - Save current server as template
*/
export function McpTemplatesSection({ onInstallTemplate, onSaveAsTemplate }: McpTemplatesSectionProps) {
const { formatMessage } = useIntl();
// State
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
// Fetch categories for filter dropdown
const { data: categories = [], isLoading: categoriesLoading } = useQuery({
queryKey: mcpTemplatesKeys.categories(),
queryFn: fetchMcpTemplateCategories,
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Mutations
const deleteMutation = useDeleteTemplate();
const installMutation = useInstallTemplate();
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchQuery]);
// Fetch templates based on search and category filter
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: mcpTemplatesKeys.list(selectedCategory === 'all' ? undefined : selectedCategory),
queryFn: async () => {
if (debouncedSearch.trim()) {
return searchMcpTemplates(debouncedSearch.trim());
} else if (selectedCategory === 'all') {
// Fetch all templates by iterating categories
const allTemplates: McpTemplate[] = [];
for (const category of categories) {
const categoryTemplates = await fetchMcpTemplatesByCategory(category);
allTemplates.push(...categoryTemplates);
}
return allTemplates;
} else {
return fetchMcpTemplatesByCategory(selectedCategory);
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !categoriesLoading,
});
// Handlers
const handleInstallTemplate = useCallback((template: McpTemplate) => {
// Call parent callback to open McpServerDialog with template data
onInstallTemplate?.(template);
}, [onInstallTemplate]);
const handleDeleteClick = useCallback((templateName: string) => {
setTemplateToDelete(templateName);
setDeleteConfirmOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!templateToDelete) return;
const result = await deleteMutation.deleteTemplate(templateToDelete);
if (result.success) {
setDeleteConfirmOpen(false);
setTemplateToDelete(null);
}
}, [templateToDelete, deleteMutation]);
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string) => {
onSaveAsTemplate?.(_name, { command: '', args: [] });
setSaveDialogOpen(false);
}, [onSaveAsTemplate]);
return (
<div className="space-y-4">
{/* Header with search and filter */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={formatMessage({ id: 'mcp.templates.searchPlaceholder' })}
className="pl-9"
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{formatMessage({ id: 'mcp.templates.filter.allCategories' })}
</SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Save Template Button */}
<Button
variant="outline"
size="sm"
onClick={() => setSaveDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
</Button>
</div>
{/* Template List */}
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.templates.loading' })}
</p>
</div>
) : templates.length === 0 ? (
<Card className="p-12 text-center">
<Copy className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'mcp.templates.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'mcp.templates.empty.message' })}
</p>
<Button onClick={() => setSaveDialogOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.templates.empty.createFirst' })}
</Button>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{templates.map((template) => (
<TemplateCard
key={template.name}
template={template}
onInstall={handleInstallTemplate}
onDelete={handleDeleteClick}
isInstalling={installMutation.isInstalling}
isDeleting={deleteMutation.isDeleting}
/>
))}
</div>
)}
{/* Save Template Dialog */}
<TemplateSaveDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={handleSaveTemplate}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.templates.deleteDialog.title' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage(
{ id: 'mcp.templates.deleteDialog.message' },
{ name: templateToDelete }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{formatMessage({ id: 'mcp.templates.deleteDialog.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isDeleting
? formatMessage({ id: 'mcp.templates.deleteDialog.deleting' })
: formatMessage({ id: 'mcp.templates.deleteDialog.delete' })
}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default McpTemplatesSection;

View File

@@ -0,0 +1,239 @@
// ========================================
// Other Projects Section Component
// ========================================
// Section for discovering and importing servers from other projects
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { FolderOpen, Copy, ChevronRight, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { useProjectOperations, useMcpServerMutations } from '@/hooks';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/lib/api';
// ========== Types ==========
export interface OtherProjectServer extends McpServer {
projectPath: string;
projectName: string;
}
export interface OtherProjectsSectionProps {
/** Callback when server is successfully imported */
onImportSuccess?: (serverName: string, sourceProject: string) => void;
/** Additional class name */
className?: string;
}
// ========== Component ==========
export function OtherProjectsSection({
onImportSuccess,
className,
}: OtherProjectsSectionProps) {
const { formatMessage } = useIntl();
const [selectedProjectPath, setSelectedProjectPath] = useState<string | null>(null);
const [otherServers, setOtherServers] = useState<OtherProjectServer[]>([]);
const [isFetchingServers, setIsFetchingServers] = useState(false);
const { projects, currentProject, fetchOtherServers, isFetchingServers: isGlobalFetching } =
useProjectOperations();
const { createServer, isCreating } = useMcpServerMutations();
// Get available projects (excluding current)
const availableProjects = projects.filter((p) => p !== currentProject);
// Handle project selection
const handleProjectSelect = async (projectPath: string) => {
setSelectedProjectPath(projectPath);
setIsFetchingServers(true);
try {
const response = await fetchOtherServers([projectPath]);
const servers: OtherProjectServer[] = [];
for (const [path, serverList] of Object.entries(response.servers)) {
const projectName = path.split(/[/\\]/).filter(Boolean).pop() || path;
for (const server of (serverList as McpServer[])) {
servers.push({
...server,
projectPath: path,
projectName,
});
}
}
setOtherServers(servers);
} catch (error) {
console.error('Failed to fetch other projects servers:', error);
setOtherServers([]);
} finally {
setIsFetchingServers(false);
}
};
// Handle server import
const handleImportServer = async (server: OtherProjectServer) => {
try {
// Generate a unique name by combining project name and server name
const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-');
await createServer({
command: server.command,
args: server.args,
env: server.env,
scope: 'project',
enabled: server.enabled,
});
onImportSuccess?.(uniqueName, server.projectPath);
} catch (error) {
console.error('Failed to import server:', error);
}
};
const isLoading = isFetchingServers || isGlobalFetching || isCreating;
const selectedProjectName = selectedProjectPath
? selectedProjectPath.split(/[/\\]/).filter(Boolean).pop()
: null;
return (
<Card className={cn('p-4', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.otherProjects.title' })}
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => selectedProjectPath && handleProjectSelect(selectedProjectPath)}
disabled={!selectedProjectPath || isLoading}
className="h-8"
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-4">
{formatMessage({ id: 'mcp.otherProjects.description' })}
</p>
{/* Project Selector */}
<div className="mb-4">
<label className="text-sm font-medium text-foreground mb-2 block">
{formatMessage({ id: 'mcp.otherProjects.selectProject' })}
</label>
<Select value={selectedProjectPath ?? ''} onValueChange={handleProjectSelect}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={formatMessage({ id: 'mcp.otherProjects.selectProjectPlaceholder' })}
/>
</SelectTrigger>
<SelectContent>
{availableProjects.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'mcp.otherProjects.noProjects' })}
</div>
) : (
availableProjects.map((path) => {
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
return (
<SelectItem key={path} value={path}>
<div className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{name}</span>
</div>
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
{/* Servers List */}
{selectedProjectPath && (
<div className="space-y-2">
{isFetchingServers ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin text-muted-foreground">-</div>
<span className="ml-2 text-sm text-muted-foreground">
{formatMessage({ id: 'common.actions.loading' })}
</span>
</div>
) : otherServers.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border border-dashed rounded-lg">
{formatMessage(
{ id: 'mcp.otherProjects.noServers' },
{ project: selectedProjectName }
)}
</div>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{otherServers.map((server) => (
<div
key={`${server.projectPath}-${server.name}`}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{server.name}
</span>
{server.enabled && (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{server.command} {(server.args || []).join(' ')}
</p>
<p className="text-xs text-muted-foreground truncate">
<span className="font-medium">{server.projectName}</span>
<ChevronRight className="inline w-3 h-3 mx-1" />
<span className="font-mono">{server.projectPath}</span>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleImportServer(server)}
disabled={isCreating}
className="flex-shrink-0"
>
<Copy className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.otherProjects.import' })}
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Hint */}
{selectedProjectPath && otherServers.length > 0 && (
<p className="text-xs text-muted-foreground mt-3">
{formatMessage({ id: 'mcp.otherProjects.hint' })}
</p>
)}
</Card>
);
}
export default OtherProjectsSection;

View File

@@ -0,0 +1,346 @@
// ========================================
// Recommended MCP Section Component
// ========================================
// Display recommended MCP servers with one-click install functionality
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Search,
Globe,
Sparkles,
Download,
Check,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
createMcpServer,
fetchMcpServers,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* Recommended server configuration
*/
export interface RecommendedServer {
id: string;
name: string;
description: string;
command: string;
args: string[];
icon: React.ComponentType<{ className?: string }>;
category: 'search' | 'browser' | 'ai';
}
/**
* Props for RecommendedMcpSection component
*/
export interface RecommendedMcpSectionProps {
/** Callback when server is installed */
onInstallComplete?: () => void;
}
interface RecommendedServerCardProps {
server: RecommendedServer;
isInstalled: boolean;
isInstalling: boolean;
onInstall: (server: RecommendedServer) => void;
}
// ========== Constants ==========
/**
* Pre-configured recommended MCP servers
*/
const RECOMMENDED_SERVERS: RecommendedServer[] = [
{
id: 'ace-tool',
name: 'ACE Tool',
description: 'Advanced code search and context engine for intelligent code discovery',
command: 'mcp__ace-tool__search_context',
args: [],
icon: Search,
category: 'search',
},
{
id: 'chrome-devtools',
name: 'Chrome DevTools',
description: 'Browser automation and debugging tools for web development',
command: 'mcp__chrome-devtools',
args: [],
icon: Globe,
category: 'browser',
},
{
id: 'exa-search',
name: 'Exa Search',
description: 'AI-powered web search with real-time crawling capabilities',
command: 'mcp__exa__search',
args: [],
icon: Sparkles,
category: 'ai',
},
];
// ========== Helper Component ==========
/**
* Individual recommended server card
*/
function RecommendedServerCard({
server,
isInstalled,
isInstalling,
onInstall,
}: RecommendedServerCardProps) {
const { formatMessage } = useIntl();
const Icon = server.icon;
return (
<Card className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
{/* Icon */}
<div className={cn(
'p-2.5 rounded-lg',
isInstalled ? 'bg-primary/20' : 'bg-muted'
)}>
<Icon className={cn(
'w-5 h-5',
isInstalled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate">
{server.name}
</h4>
{isInstalled && (
<Badge variant="default" className="text-xs">
{formatMessage({ id: 'mcp.recommended.actions.installed' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
{server.description}
</p>
{/* Install Button */}
{!isInstalled && (
<Button
variant="outline"
size="sm"
onClick={() => onInstall(server)}
disabled={isInstalling}
className="w-full"
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
)}
{/* Installed Indicator */}
{isInstalled && (
<div className="flex items-center gap-1 text-xs text-primary">
<Check className="w-4 h-4" />
<span>{formatMessage({ id: 'mcp.recommended.actions.installed' })}</span>
</div>
)}
</div>
</div>
</Card>
);
}
// ========== Main Component ==========
/**
* Recommended MCP servers section with one-click install
*/
export function RecommendedMcpSection({
onInstallComplete,
}: RecommendedMcpSectionProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success, error } = useNotifications();
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<RecommendedServer | null>(null);
const [installingServerId, setInstallingServerId] = useState<string | null>(null);
const [installedServerIds, setInstalledServerIds] = useState<Set<string>>(new Set());
// Check which servers are already installed
const checkInstalledServers = async () => {
try {
const data = await fetchMcpServers();
const allServers = [...data.project, ...data.global];
const installedIds = new Set(
allServers
.filter(s => s.command.startsWith('mcp__'))
.map(s => s.command)
);
setInstalledServerIds(installedIds);
} catch {
// Ignore errors during check
}
};
// Check on mount
useState(() => {
checkInstalledServers();
});
// Create server mutation
const createMutation = useMutation({
mutationFn: (server: Omit<RecommendedServer, 'id' | 'icon' | 'category'>) =>
createMcpServer({
command: server.command,
args: server.args,
scope: 'global',
enabled: true,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
setInstalledServerIds(prev => new Set(prev).add(variables.command));
setInstallingServerId(null);
setConfirmDialogOpen(false);
setSelectedServer(null);
success(
formatMessage({ id: 'mcp.recommended.actions.installed' }),
formatMessage({ id: 'mcp.recommended.servers.' + selectedServer?.id + '.name' })
);
onInstallComplete?.();
},
onError: () => {
setInstallingServerId(null);
error(
formatMessage({ id: 'mcp.dialog.validation.nameRequired' }),
formatMessage({ id: 'mcp.dialog.validation.commandRequired' })
);
},
});
// Handle install click
const handleInstallClick = (server: RecommendedServer) => {
setSelectedServer(server);
setConfirmDialogOpen(true);
};
// Handle confirm install
const handleConfirmInstall = () => {
if (!selectedServer) return;
setInstallingServerId(selectedServer.id);
setConfirmDialogOpen(false);
createMutation.mutate(selectedServer);
};
// Check if server is installed
const isServerInstalled = (server: RecommendedServer) => {
return installedServerIds.has(server.command);
};
return (
<>
<section className="space-y-4">
{/* Header */}
<div>
<h2 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'mcp.recommended.title' })}
</h2>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.recommended.description' })}
</p>
</div>
{/* Server Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{RECOMMENDED_SERVERS.map((server) => (
<RecommendedServerCard
key={server.id}
server={server}
isInstalled={isServerInstalled(server)}
isInstalling={installingServerId === server.id}
onInstall={handleInstallClick}
/>
))}
</div>
</section>
{/* Confirmation Dialog */}
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'mcp.recommended.actions.install' })} {selectedServer?.name}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{formatMessage(
{ id: 'mcp.recommended.description' },
{ server: selectedServer?.name }
)}
</p>
<div className="mt-4 p-3 bg-muted rounded-lg">
<code className="text-xs font-mono">
{selectedServer?.command} {selectedServer?.args.join(' ')}
</code>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDialogOpen(false)}
disabled={createMutation.isPending}
>
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
</Button>
<Button
onClick={handleConfirmInstall}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default RecommendedMcpSection;

View File

@@ -0,0 +1,272 @@
// ========================================
// Windows Compatibility Warning Component
// ========================================
// Windows-specific warning banner with command detection and auto-fix functionality
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Download, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface WindowsCompatibilityWarningProps {
/** Optional: Project path to check commands against */
projectPath?: string;
/** Optional: Callback when compatibility check completes */
onComplete?: (result: CompatibilityCheckResult) => void;
}
export interface CommandDetectionResult {
command: string;
available: boolean;
installUrl?: string;
}
export interface CompatibilityCheckResult {
isWindows: boolean;
missingCommands: string[];
commands: CommandDetectionResult[];
canAutoFix: boolean;
}
// ========== Constants ==========
const COMMON_COMMANDS = [
{ name: 'npm', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
{ name: 'node', installUrl: 'https://nodejs.org/' },
{ name: 'python', installUrl: 'https://www.python.org/downloads/' },
{ name: 'npx', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
];
// Helper function to check if a command can be auto-fixed
function canAutoFixCommand(command: string): boolean {
return COMMON_COMMANDS.some((cmd) => cmd.name === command);
}
// ========== API Functions ==========
async function detectWindowsCommands(projectPath?: string): Promise<CommandDetectionResult[]> {
const response = await fetch('/api/mcp/detect-commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath }),
});
if (!response.ok) {
throw new Error('Failed to detect commands');
}
return response.json();
}
async function applyWindowsAutoFix(projectPath?: string): Promise<{ success: boolean; message: string }> {
const response = await fetch('/api/mcp/apply-windows-fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath }),
});
if (!response.ok) {
throw new Error('Failed to apply Windows fix');
}
return response.json();
}
// ========== Sub-Components ==========
interface CommandDetectionListProps {
commands: CommandDetectionResult[];
}
function CommandDetectionList({ commands }: CommandDetectionListProps) {
const { formatMessage } = useIntl();
return (
<div className="space-y-2 mt-3">
{commands.map((cmd) => (
<div
key={cmd.command}
className={cn(
'flex items-center gap-2 text-sm p-2 rounded-md',
cmd.available ? 'bg-success/10' : 'bg-destructive/10'
)}
>
{cmd.available ? (
<CheckCircle className="w-4 h-4 text-success" />
) : (
<XCircle className="w-4 h-4 text-destructive" />
)}
<code className="font-mono flex-1">{cmd.command}</code>
{!cmd.available && cmd.installUrl && (
<a
href={cmd.installUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="w-3 h-3" />
{formatMessage({ id: 'mcp.windows.install' })}
</a>
)}
</div>
))}
</div>
);
}
// ========== Main Component ==========
export function WindowsCompatibilityWarning({
projectPath,
onComplete,
}: WindowsCompatibilityWarningProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const [checkResult, setCheckResult] = useState<CompatibilityCheckResult | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Detect Windows platform
const isWindows = typeof window !== 'undefined' && window.navigator.platform.toLowerCase().includes('win');
// Mutation for auto-fix
const autoFixMutation = useMutation({
mutationFn: () => applyWindowsAutoFix(projectPath),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
if (onComplete && checkResult) {
onComplete({ ...checkResult, canAutoFix: false });
}
},
});
// Check compatibility on mount (Windows only)
useEffect(() => {
if (!isWindows || dismissed) return;
const checkCompatibility = async () => {
setIsChecking(true);
try {
const results = await detectWindowsCommands(projectPath);
const missingCommands = results.filter((r) => !r.available).map((r) => r.command);
const canAutoFix = missingCommands.length > 0 && missingCommands.every(canAutoFixCommand);
const result: CompatibilityCheckResult = {
isWindows: true,
missingCommands,
commands: results,
canAutoFix,
};
setCheckResult(result);
if (onComplete) {
onComplete(result);
}
} catch (error) {
console.error('Failed to check Windows compatibility:', error);
} finally {
setIsChecking(false);
}
};
checkCompatibility();
}, [isWindows, dismissed, projectPath, onComplete]);
// Don't render if not Windows, dismissed, or still checking without errors
if (!isWindows || dismissed || (isChecking && !checkResult)) {
return null;
}
// Don't show warning if all commands are available
if (checkResult && checkResult.missingCommands.length === 0) {
return null;
}
const handleAutoFix = async () => {
autoFixMutation.mutate();
};
const handleDismiss = () => {
setDismissed(true);
};
return (
<div className="border border-warning/50 bg-warning/5 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'mcp.windows.title' })}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.windows.description' })}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleDismiss}
>
×
</Button>
</div>
{checkResult && (
<>
{checkResult.missingCommands.length > 0 && (
<div className="flex items-center gap-2">
<Badge variant="warning" className="text-xs">
{formatMessage({ id: 'mcp.windows.missingCount' }, { count: checkResult.missingCommands.length })}
</Badge>
</div>
)}
<CommandDetectionList commands={checkResult.commands} />
{checkResult.canAutoFix && (
<div className="flex items-center gap-2 pt-2 border-t border-warning/20">
<Button
variant="outline"
size="sm"
onClick={handleAutoFix}
disabled={autoFixMutation.isPending}
className="text-xs"
>
{autoFixMutation.isPending ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.windows.fixing' })}
</>
) : (
<>
<CheckCircle className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.windows.autoFix' })}
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.windows.autoFixHint' })}
</span>
</div>
)}
</>
)}
{isChecking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{formatMessage({ id: 'mcp.windows.checking' })}
</div>
)}
</div>
</div>
</div>
);
}
export default WindowsCompatibilityWarning;

View File

@@ -0,0 +1,260 @@
// ========================================
// Context Assembler Component
// ========================================
// Provides UI for managing context assembly rules
// with variable interpolation templates
import * as React from "react";
import { useIntl } from "react-intl";
import { Info, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ContextRule {
nodeId: string;
label?: string;
variable?: string;
includeOutput?: boolean;
transform?: "raw" | "json" | "markdown" | "summary";
}
export interface ContextAssemblerProps {
value: string; // Template with {{node:nodeId}} or {{var:variableName}} syntax
onChange: (value: string) => void;
availableNodes: Array<{ id: string; label: string; type: string; outputVariable?: string }>;
availableVariables?: string[];
className?: string;
}
const TRANSFORM_OPTIONS = [
{ value: "raw", label: "Raw Output" },
{ value: "json", label: "JSON Format" },
{ value: "markdown", label: "Markdown" },
{ value: "summary", label: "Summary" },
] as const;
const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>(
({ value, onChange, availableNodes, availableVariables = [], className }, ref) => {
const { formatMessage } = useIntl();
const [showHelp, setShowHelp] = React.useState(false);
const [rules, setRules] = React.useState<ContextRule[]>([]);
// Parse template to extract existing rules
React.useEffect(() => {
const extracted: ContextRule[] = [];
const nodeRegex = /\{\{node:([^}]+)\}\}/g;
const varRegex = /\{\{var:([^}]+)\}\}/g;
let match;
while ((match = nodeRegex.exec(value)) !== null) {
const node = availableNodes.find((n) => n.id === match[1]);
extracted.push({
nodeId: match[1],
label: node?.label,
variable: node?.outputVariable,
includeOutput: true,
transform: "raw",
});
}
while ((match = varRegex.exec(value)) !== null) {
extracted.push({
nodeId: "",
variable: match[1],
includeOutput: true,
transform: "raw",
});
}
setRules(extracted);
}, [value, availableNodes]);
const updateTemplate = React.useCallback(
(newRules: ContextRule[]) => {
let template = value;
// Remove all existing node and var references
template = template.replace(/\{\{node:[^}]+\}\}/g, "").replace(/\{\{var:[^}]+\}\}/g, "");
// Build new template with rules
const sections: string[] = [];
newRules.forEach((rule, index) => {
const prefix = rule.nodeId ? "node" : "var";
const ref = rule.nodeId || rule.variable;
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
const label = rule.label || node?.label || ref;
if (rule.includeOutput) {
sections.push(`## ${label || `Source ${index + 1}`}`);
sections.push(`{{${prefix}:${ref}}}`);
sections.push("");
}
});
onChange(sections.join("\n"));
},
[value, availableNodes, onChange]
);
const addNode = (nodeId: string) => {
const node = availableNodes.find((n) => n.id === nodeId);
if (node && !rules.find((r) => r.nodeId === nodeId)) {
const newRules = [...rules, { nodeId, label: node.label, variable: node.outputVariable, includeOutput: true, transform: "raw" }];
setRules(newRules);
updateTemplate(newRules);
}
};
const addVariable = (variableName: string) => {
if (!rules.find((r) => r.variable === variableName && !r.nodeId)) {
const newRules = [...rules, { nodeId: "", variable: variableName, includeOutput: true, transform: "raw" }];
setRules(newRules);
updateTemplate(newRules);
}
};
const removeRule = (index: number) => {
const newRules = rules.filter((_, i) => i !== index);
setRules(newRules);
updateTemplate(newRules);
};
const updateRuleTransform = (index: number, transform: ContextRule["transform"]) => {
const newRules = [...rules];
newRules[index] = { ...newRules[index], transform };
setRules(newRules);
updateTemplate(newRules);
};
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* Header with help toggle */}
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: "orchestrator.contextAssembler.title" })}
</label>
<button
type="button"
onClick={() => setShowHelp(!showHelp)}
className="text-muted-foreground hover:text-foreground"
>
<Info className="w-4 h-4" />
</button>
</div>
{/* Help panel */}
{showHelp && (
<div className="p-3 rounded-md bg-muted/50 border border-border text-xs text-muted-foreground">
<p className="mb-2 font-medium text-foreground">
{formatMessage({ id: "orchestrator.contextAssembler.helpTitle" })}
</p>
<ul className="space-y-1 list-disc list-inside">
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax1" })}</li>
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax2" })}</li>
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax3" })}</li>
</ul>
</div>
)}
{/* Current rules list */}
{rules.length > 0 && (
<div className="space-y-2">
{rules.map((rule, index) => {
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
const label = rule.label || node?.label || rule.variable || `Source ${index + 1}`;
return (
<div key={index} className="flex items-center gap-2 p-2 rounded-md border border-border bg-background">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{label}</div>
<div className="text-xs text-muted-foreground">
{rule.nodeId ? `{{node:${rule.nodeId}}}` : `{{var:${rule.variable}}}`}
</div>
</div>
<select
value={rule.transform || "raw"}
onChange={(e) => updateRuleTransform(index, e.target.value as ContextRule["transform"])}
className="h-8 px-2 text-xs rounded border border-border bg-background"
>
{TRANSFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="button"
onClick={() => removeRule(index)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
)}
{/* Add node selector */}
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.addNode" })}
</label>
<select
value=""
onChange={(e) => e.target.value && addNode(e.target.value)}
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
>
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectNode" })}</option>
{availableNodes
.filter((n) => !rules.find((r) => r.nodeId === n.id))
.map((node) => (
<option key={node.id} value={node.id}>
{node.label || node.id} ({node.type})
</option>
))}
</select>
</div>
{/* Add variable selector */}
{availableVariables.length > 0 && (
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.addVariable" })}
</label>
<select
value=""
onChange={(e) => e.target.value && addVariable(e.target.value)}
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
>
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectVariable" })}</option>
{availableVariables
.filter((v) => !rules.find((r) => r.variable === v && !r.nodeId))
.map((variableName) => (
<option key={variableName} value={variableName}>
{`{{${variableName}}}`}
</option>
))}
</select>
</div>
)}
{/* Manual template editor */}
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.manualEdit" })}
</label>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={formatMessage({ id: "orchestrator.propertyPanel.placeholders.contextTemplate" })}
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-sm resize-none font-mono"
/>
</div>
</div>
);
}
);
ContextAssembler.displayName = "ContextAssembler";
export { ContextAssembler };

View File

@@ -15,6 +15,9 @@ export type { UseConfigReturn } from './useConfig';
export { useNotifications } from './useNotifications';
export type { UseNotificationsReturn, ToastOptions } from './useNotifications';
export { useWebSocket } from './useWebSocket';
export type { UseWebSocketOptions, UseWebSocketReturn } from './useWebSocket';
export { useWebSocketNotifications } from './useWebSocketNotifications';
export { useSystemNotifications } from './useSystemNotifications';
@@ -140,7 +143,13 @@ export {
useDeleteMcpServer,
useToggleMcpServer,
useMcpServerMutations,
useMcpTemplates,
useCreateTemplate,
useDeleteTemplate,
useInstallTemplate,
useProjectOperations,
mcpServersKeys,
mcpTemplatesKeys,
} from './useMcpServers';
export type {
UseMcpServersOptions,
@@ -149,6 +158,12 @@ export type {
UseCreateMcpServerReturn,
UseDeleteMcpServerReturn,
UseToggleMcpServerReturn,
UseMcpTemplatesOptions,
UseMcpTemplatesReturn,
UseCreateTemplateReturn,
UseDeleteTemplateReturn,
UseInstallTemplateReturn,
UseProjectOperationsReturn,
} from './useMcpServers';
// ========== CLI ==========

View File

@@ -10,8 +10,23 @@ import {
createMcpServer,
deleteMcpServer,
toggleMcpServer,
fetchMcpTemplates,
saveMcpTemplate,
deleteMcpTemplate,
installMcpTemplate,
codexRemoveServer,
codexToggleServer,
fetchAllProjects,
fetchOtherProjectsServers,
crossCliCopy,
type McpServer,
type McpServersResponse,
type McpTemplate,
type McpTemplateInstallRequest,
type AllProjectsResponse,
type OtherProjectsServersResponse,
type CrossCliCopyRequest,
type CrossCliCopyResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -22,6 +37,22 @@ export const mcpServersKeys = {
list: (scope?: 'project' | 'global') => [...mcpServersKeys.lists(), scope] as const,
};
// Query key factory for MCP templates
export const mcpTemplatesKeys = {
all: ['mcpTemplates'] as const,
lists: () => [...mcpTemplatesKeys.all, 'list'] as const,
list: (category?: string) => [...mcpTemplatesKeys.lists(), category] as const,
search: (query: string) => [...mcpTemplatesKeys.all, 'search', query] as const,
categories: () => [...mcpTemplatesKeys.all, 'categories'] as const,
};
// Query key factory for projects
export const projectsKeys = {
all: ['projects'] as const,
list: () => [...projectsKeys.all, 'list'] as const,
servers: (paths?: string[]) => [...projectsKeys.all, 'servers', ...(paths ?? [])] as const,
};
// Default stale time: 2 minutes (MCP servers change occasionally)
const STALE_TIME = 2 * 60 * 1000;
@@ -229,3 +260,267 @@ export function useMcpServerMutations() {
isMutating: update.isUpdating || create.isCreating || remove.isDeleting || toggle.isToggling,
};
}
// ========================================
// MCP Template Hooks
// ========================================
// Default stale time for templates: 5 minutes (templates change rarely)
const TEMPLATES_STALE_TIME = 5 * 60 * 1000;
export interface UseMcpTemplatesOptions {
category?: string;
staleTime?: number;
enabled?: boolean;
}
export interface UseMcpTemplatesReturn {
templates: McpTemplate[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching MCP templates with optional category filter
*/
export function useMcpTemplates(options: UseMcpTemplatesOptions = {}): UseMcpTemplatesReturn {
const { category, staleTime = TEMPLATES_STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: mcpTemplatesKeys.list(category),
queryFn: () => fetchMcpTemplates(),
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
};
return {
templates: category
? query.data?.filter((t) => t.category === category) ?? []
: query.data ?? [],
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
export interface UseCreateTemplateReturn {
createTemplate: (template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>) => Promise<{ success: boolean; id?: number; error?: string }>;
isCreating: boolean;
error: Error | null;
}
/**
* Hook for creating or updating MCP templates
*/
export function useCreateTemplate(): UseCreateTemplateReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>) =>
saveMcpTemplate(template),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
},
});
return {
createTemplate: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteTemplateReturn {
deleteTemplate: (templateName: string) => Promise<{ success: boolean; error?: string }>;
isDeleting: boolean;
error: Error | null;
}
/**
* Hook for deleting MCP templates
*/
export function useDeleteTemplate(): UseDeleteTemplateReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (templateName: string) => deleteMcpTemplate(templateName),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
},
});
return {
deleteTemplate: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
export interface UseInstallTemplateReturn {
installTemplate: (request: McpTemplateInstallRequest) => Promise<{ success: boolean; serverName?: string; error?: string }>;
isInstalling: boolean;
error: Error | null;
}
/**
* Hook for installing MCP templates to project or global scope
*/
export function useInstallTemplate(): UseInstallTemplateReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (request: McpTemplateInstallRequest) => installMcpTemplate(request),
onSettled: () => {
// Invalidate both templates and servers since installation affects both
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
},
});
return {
installTemplate: mutation.mutateAsync,
isInstalling: mutation.isPending,
error: mutation.error,
};
}
// ========================================
// Codex MCP Hooks
// ========================================
export interface UseCodexMutationsReturn {
removeServer: (serverName: string) => Promise<{ success: boolean; error?: string }>;
toggleServer: (serverName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
isRemoving: boolean;
isToggling: boolean;
error: Error | null;
}
/**
* Combined hook for Codex MCP mutations (remove and toggle)
*/
export function useCodexMutations(): UseCodexMutationsReturn {
const queryClient = useQueryClient();
const removeMutation = useMutation({
mutationFn: (serverName: string) => codexRemoveServer(serverName),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
},
});
const toggleMutation = useMutation({
mutationFn: ({ serverName, enabled }: { serverName: string; enabled: boolean }) =>
codexToggleServer(serverName, enabled),
onMutate: async ({ serverName, enabled }) => {
// Optimistic update could be added here if needed
return { serverName, enabled };
},
onError: (_error, _vars, context) => {
// Rollback on error
console.error('Failed to toggle Codex MCP server:', _error);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
},
});
return {
removeServer: removeMutation.mutateAsync,
isRemoving: removeMutation.isPending,
toggleServer: (serverName, enabled) => toggleMutation.mutateAsync({ serverName, enabled }),
isToggling: toggleMutation.isPending,
error: removeMutation.error || toggleMutation.error,
};
}
// ========================================
// Project Operations Hooks
// ========================================
export interface UseProjectOperationsReturn {
projects: string[];
currentProject?: string;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
copyToCodex: (request: CrossCliCopyRequest) => Promise<CrossCliCopyResponse>;
copyFromCodex: (request: CrossCliCopyRequest) => Promise<CrossCliCopyResponse>;
isCopying: boolean;
fetchOtherServers: (projectPaths?: string[]) => Promise<OtherProjectsServersResponse>;
isFetchingServers: boolean;
}
/**
* Combined hook for project operations (all projects, cross-CLI copy, other projects' servers)
*/
export function useProjectOperations(): UseProjectOperationsReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Fetch all projects
const projectsQuery = useQuery({
queryKey: projectsKeys.list(),
queryFn: () => fetchAllProjects(),
staleTime: STALE_TIME,
enabled: true,
retry: 2,
});
// Cross-CLI copy mutation
const copyMutation = useMutation({
mutationFn: (request: CrossCliCopyRequest) => crossCliCopy(request),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
},
});
// Other projects servers query
const serversQuery = useQuery({
queryKey: projectsKeys.servers(),
queryFn: () => fetchOtherProjectsServers(),
staleTime: STALE_TIME,
enabled: false, // Manual trigger only
retry: 1,
});
const refetch = async () => {
await projectsQuery.refetch();
};
const fetchOtherServers = async (projectPaths?: string[]) => {
return await queryClient.fetchQuery({
queryKey: projectsKeys.servers(projectPaths),
queryFn: () => fetchOtherProjectsServers(projectPaths),
staleTime: STALE_TIME,
});
};
return {
projects: projectsQuery.data?.projects ?? [],
currentProject: projectsQuery.data?.currentProject ?? projectPath ?? undefined,
isLoading: projectsQuery.isLoading,
error: projectsQuery.error,
refetch,
copyToCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'claude', target: 'codex' }),
copyFromCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'codex', target: 'claude' }),
isCopying: copyMutation.isPending,
fetchOtherServers,
isFetchingServers: serversQuery.isFetching,
};
}

View File

@@ -3,7 +3,7 @@
// ========================================
// Typed fetch functions for API communication with CSRF token handling
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion } from '../types/store';
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse } from '../types/store';
// Re-export types for backward compatibility
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion };
@@ -2108,6 +2108,136 @@ export async function addCodexMcpServer(server: Omit<McpServer, 'name'>): Promis
});
}
/**
* Remove MCP server from Codex config.toml
*/
export async function codexRemoveServer(serverName: string): Promise<{ success: boolean; error?: string }> {
return fetchApi<{ success: boolean; error?: string }>('/api/codex-mcp-remove', {
method: 'POST',
body: JSON.stringify({ serverName }),
});
}
/**
* Toggle Codex MCP server enabled state
*/
export async function codexToggleServer(
serverName: string,
enabled: boolean
): Promise<{ success: boolean; error?: string }> {
return fetchApi<{ success: boolean; error?: string }>('/api/codex-mcp-toggle', {
method: 'POST',
body: JSON.stringify({ serverName, enabled }),
});
}
// ========== MCP Templates API ==========
/**
* Fetch all MCP templates from database
*/
export async function fetchMcpTemplates(): Promise<McpTemplate[]> {
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>('/api/mcp-templates');
return data.templates ?? [];
}
/**
* Save or update MCP template
*/
export async function saveMcpTemplate(
template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>
): Promise<{ success: boolean; id?: number; error?: string }> {
return fetchApi<{ success: boolean; id?: number; error?: string }>('/api/mcp-templates', {
method: 'POST',
body: JSON.stringify(template),
});
}
/**
* Delete MCP template by name
*/
export async function deleteMcpTemplate(templateName: string): Promise<{ success: boolean; error?: string }> {
return fetchApi<{ success: boolean; error?: string }>(
`/api/mcp-templates/${encodeURIComponent(templateName)}`,
{ method: 'DELETE' }
);
}
/**
* Install MCP template to project or global scope
*/
export async function installMcpTemplate(
request: McpTemplateInstallRequest
): Promise<{ success: boolean; serverName?: string; error?: string }> {
return fetchApi<{ success: boolean; serverName?: string; error?: string }>('/api/mcp-templates/install', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Search MCP templates by keyword
*/
export async function searchMcpTemplates(keyword: string): Promise<McpTemplate[]> {
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>(
`/api/mcp-templates/search?q=${encodeURIComponent(keyword)}`
);
return data.templates ?? [];
}
/**
* Get all MCP template categories
*/
export async function fetchMcpTemplateCategories(): Promise<string[]> {
const data = await fetchApi<{ success: boolean; categories: string[] }>('/api/mcp-templates/categories');
return data.categories ?? [];
}
/**
* Get MCP templates by category
*/
export async function fetchMcpTemplatesByCategory(category: string): Promise<McpTemplate[]> {
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>(
`/api/mcp-templates/category/${encodeURIComponent(category)}`
);
return data.templates ?? [];
}
// ========== Projects API ==========
/**
* Fetch all projects for cross-project operations
*/
export async function fetchAllProjects(): Promise<AllProjectsResponse> {
return fetchApi<AllProjectsResponse>('/api/projects/all');
}
/**
* Fetch MCP servers from other projects
*/
export async function fetchOtherProjectsServers(
projectPaths?: string[]
): Promise<OtherProjectsServersResponse> {
const url = projectPaths
? `/api/projects/other-servers?paths=${projectPaths.map(p => encodeURIComponent(p)).join(',')}`
: '/api/projects/other-servers';
return fetchApi<OtherProjectsServersResponse>(url);
}
// ========== Cross-CLI Operations ==========
/**
* Copy MCP servers between Claude and Codex CLIs
*/
export async function crossCliCopy(
request: CrossCliCopyRequest
): Promise<CrossCliCopyResponse> {
return fetchApi<CrossCliCopyResponse>('/api/mcp/cross-cli-copy', {
method: 'POST',
body: JSON.stringify(request),
});
}
// ========== CLI Endpoints API ==========
export interface CliEndpoint {

View File

@@ -61,29 +61,126 @@
"updatedAt": "Updated",
"solutions": "{count, plural, one {solution} other {solutions}}"
},
"detail": {
"title": "Issue Details",
"tabs": {
"overview": "Overview",
"solutions": "Solutions",
"history": "History",
"json": "JSON"
},
"overview": {
"title": "Title",
"status": "Status",
"priority": "Priority",
"createdAt": "Created At",
"updatedAt": "Updated At",
"context": "Context",
"labels": "Labels",
"assignee": "Assignee"
},
"solutions": {
"title": "Solutions",
"empty": "No solutions yet",
"addSolution": "Add Solution",
"boundSolution": "Bound Solution"
},
"history": {
"title": "History",
"empty": "No history yet"
}
},
"queue": {
"title": "Queue",
"pageTitle": "Issue Queue",
"description": "Manage issue execution queue with execution groups",
"status": {
"pending": "Pending",
"ready": "Ready",
"executing": "Executing",
"completed": "Completed",
"failed": "Failed",
"blocked": "Blocked",
"active": "Active",
"inactive": "Inactive"
},
"stats": {
"totalItems": "Total Items",
"groups": "Groups",
"tasks": "Tasks",
"solutions": "Solutions"
"solutions": "Solutions",
"items": "Items",
"executionGroups": "Execution Groups"
},
"actions": {
"activate": "Activate",
"deactivate": "Deactivate",
"delete": "Delete",
"merge": "Merge",
"split": "Split",
"confirmDelete": "Are you sure you want to delete this queue?"
},
"executionGroup": "Execution Group",
"executionGroups": "Execution Groups",
"parallelGroup": "Parallel Group",
"sequentialGroup": "Sequential Group",
"items": "items",
"itemCount": "{count} items",
"groups": "groups",
"parallel": "Parallel",
"sequential": "Sequential",
"emptyState": "No queue data available",
"empty": "No data",
"conflicts": "Conflicts detected in queue",
"noQueueData": "No queue data"
"noQueueData": "No queue data",
"error": {
"title": "Load Failed",
"message": "Unable to load queue data, please try again later"
},
"conflicts": {
"title": "Queue Conflicts",
"description": "conflicts"
},
"deleteDialog": {
"title": "Delete Queue",
"description": "Are you sure you want to delete this queue? This action cannot be undone."
},
"mergeDialog": {
"title": "Merge Queues",
"targetQueueLabel": "Target Queue ID",
"targetQueuePlaceholder": "Enter the queue ID to merge into"
},
"splitDialog": {
"title": "Split Queue",
"selected": "{count}/{total} selected",
"selectAll": "Select All",
"clearAll": "Clear All",
"noSelection": "Please select items to split",
"cannotSplitAll": "Cannot split all items, source queue must retain at least one item"
}
},
"solution": {
"issue": "Issue",
"solution": "Solution",
"shortIssue": "Issue",
"shortSolution": "Sol",
"tabs": {
"overview": "Overview",
"tasks": "Tasks",
"json": "JSON"
},
"overview": {
"executionInfo": "Execution Info",
"executionOrder": "Execution Order",
"semanticPriority": "Semantic Priority",
"group": "Execution Group",
"taskCount": "Task Count",
"dependencies": "Dependencies",
"filesTouched": "Files Touched"
},
"tasks": {
"comingSoon": "Task list coming soon"
}
},
"discovery": {
"title": "Discovery",

View File

@@ -0,0 +1,196 @@
{
"title": "Issues",
"description": "Track and manage issues",
"status": {
"open": "Open",
"inProgress": "In Progress",
"resolved": "Resolved",
"closed": "Closed",
"completed": "Completed"
},
"priority": {
"low": "Low",
"medium": "Medium",
"high": "High",
"critical": "Critical"
},
"actions": {
"create": "New Issue",
"edit": "Edit",
"delete": "Delete",
"viewDetails": "View Details",
"changeStatus": "Change Status",
"changePriority": "Change Priority",
"startProgress": "Start Progress",
"markResolved": "Mark Resolved",
"github": "Pull from GitHub"
},
"filters": {
"all": "All",
"open": "Open",
"inProgress": "In Progress",
"resolved": "Resolved",
"closed": "Closed",
"byPriority": "By Priority"
},
"emptyState": {
"title": "No Issues Found",
"message": "No issues match your current filter.",
"createFirst": "Create your first issue to get started"
},
"createDialog": {
"title": "Create New Issue",
"labels": {
"title": "Title",
"context": "Context",
"priority": "Priority"
},
"placeholders": {
"title": "Enter issue title...",
"context": "Describe the issue context..."
},
"buttons": {
"create": "Create",
"cancel": "Cancel",
"creating": "Creating..."
}
},
"card": {
"id": "ID",
"createdAt": "Created",
"updatedAt": "Updated",
"solutions": "{count, plural, one {solution} other {solutions}}"
},
"queue": {
"title": "Queue",
"pageTitle": "Issue Queue",
"description": "Manage issue execution queue with execution groups",
"stats": {
"totalItems": "Total Items",
"groups": "Groups",
"tasks": "Tasks",
"solutions": "Solutions"
},
"actions": {
"activate": "Activate",
"deactivate": "Deactivate",
"delete": "Delete",
"merge": "Merge",
"confirmDelete": "Are you sure you want to delete this queue?"
},
"executionGroup": "Execution Group",
"parallel": "Parallel",
"sequential": "Sequential",
"emptyState": "No queue data available",
"conflicts": "Conflicts detected in queue",
"noQueueData": "No queue data"
},
"discovery": {
"title": "Discovery",
"pageTitle": "Issue Discovery",
"description": "View and manage issue discovery sessions",
"totalSessions": "Total Sessions",
"completedSessions": "Completed",
"runningSessions": "Running",
"totalFindings": "Findings",
"sessionList": "Session List",
"noSessions": "No sessions found",
"noSessionsDescription": "Start a new discovery session to begin",
"findingsDetail": "Findings Detail",
"selectSession": "Select a session to view findings",
"sessionId": "Session ID",
"name": "Name",
"status": "Status",
"createdAt": "Created At",
"completedAt": "Completed At",
"progress": "Progress",
"findingsCount": "Findings Count",
"export": "Export JSON",
"exportSelected": "Export Selected ({count})",
"exporting": "Exporting...",
"exportAsIssues": "Export as Issues",
"severityBreakdown": "Severity Breakdown",
"typeBreakdown": "Type Breakdown",
"tabFindings": "Findings",
"tabProgress": "Progress",
"tabInfo": "Session Info",
"stats": {
"totalSessions": "Total Sessions",
"completed": "Completed",
"running": "Running",
"findings": "Findings"
},
"session": {
"status": {
"running": "Running",
"completed": "Completed",
"failed": "Failed"
},
"findings": "{count} findings",
"startedAt": "Started"
},
"findings": {
"title": "Findings",
"filters": {
"severity": "Severity",
"type": "Type",
"search": "Search findings..."
},
"severity": {
"all": "All Severities",
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"type": {
"all": "All Types"
},
"exportedStatus": {
"all": "All Export Status",
"exported": "Exported",
"notExported": "Not Exported"
},
"issueStatus": {
"all": "All Issue Status",
"hasIssue": "Has Issue",
"noIssue": "No Issue"
},
"noFindings": "No findings found",
"noFindingsDescription": "No matching findings found",
"searchPlaceholder": "Search findings...",
"filterBySeverity": "Filter by severity",
"filterByType": "Filter by type",
"filterByExported": "Filter by export status",
"filterByIssue": "Filter by issue link",
"allSeverities": "All severities",
"allTypes": "All types",
"showingCount": "Showing {count} findings",
"exported": "Exported",
"hasIssue": "Linked",
"export": "Export",
"selectAll": "Select All",
"deselectAll": "Deselect All"
},
"tabs": {
"findings": "Findings",
"progress": "Progress",
"info": "Session Info"
},
"emptyState": "No discovery sessions found",
"noSessionSelected": "Select a session to view findings",
"actions": {
"export": "Export Findings",
"refresh": "Refresh"
}
},
"hub": {
"title": "Issue Hub",
"description": "Unified management for issues, queues, and discoveries",
"tabs": {
"issues": "Issues",
"queue": "Queue",
"discovery": "Discovery"
}
}
}

View File

@@ -1,6 +1,11 @@
{
"title": "MCP Servers",
"description": "Manage Model Context Protocol (MCP) servers for cross-CLI integration",
"tabs": {
"templates": "Templates",
"servers": "Servers",
"crossCli": "Cross-CLI"
},
"mode": {
"claude": "Claude",
"codex": "Codex"
@@ -25,7 +30,26 @@
"codex": {
"configPath": "Config Path",
"readOnly": "Read-only",
"readOnlyNotice": "Codex MCP servers are managed via config.toml and cannot be edited here."
"readOnlyNotice": "Codex MCP servers are managed via config.toml and cannot be edited here.",
"editable": "Editable",
"editableNotice": "This server can be edited. Changes will be saved to config.toml.",
"deleteConfirm": {
"title": "Remove Server \"{name}\"?",
"description": "This will remove \"{name}\" from your Codex config.toml file. This action cannot be undone.",
"confirm": "Remove Server",
"cancel": "Cancel",
"deleting": "Removing..."
}
},
"windows": {
"title": "Windows Compatibility",
"description": "Some MCP server commands require Windows-specific configuration for proper execution.",
"missingCount": "{count} missing command(s)",
"checking": "Checking Windows compatibility...",
"fixing": "Applying Windows compatibility fix...",
"autoFix": "Auto-Fix Commands",
"autoFixHint": "This will wrap commands with 'cmd /c' prefix for Windows compatibility.",
"install": "Download"
},
"filters": {
"all": "All",
@@ -126,5 +150,148 @@
"saveConfig": "Save Configuration",
"saving": "Saving..."
}
},
"recommended": {
"title": "Recommended Servers",
"description": "Quickly install popular MCP servers with one click",
"servers": {
"ace": {
"name": "ACE Tool",
"description": "Advanced code search and context engine for intelligent code discovery",
"command": "mcp__ace-tool__search_context",
"install": "Install ACE",
"installing": "Installing..."
},
"chrome": {
"name": "Chrome DevTools",
"description": "Browser automation and debugging tools for web development",
"command": "mcp__chrome-devtools",
"install": "Install Chrome",
"installing": "Installing..."
},
"exa": {
"name": "Exa Search",
"description": "AI-powered web search with real-time crawling capabilities",
"command": "mcp__exa__search",
"install": "Install Exa",
"installing": "Installing..."
}
},
"actions": {
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"viewAll": "View All Servers"
}
},
"configType": {
"label": "Config Format",
"mcpJson": ".mcp.json",
"claudeJson": ".claude.json",
"switchWarning": "Switching config format will not migrate existing servers. You'll need to reconfigure servers in the new format.",
"switchConfirm": "Switch Config Format",
"switchCancel": "Keep Current"
},
"templates": {
"title": "Templates",
"description": "Reusable MCP server configuration templates",
"searchPlaceholder": "Search templates by name or description...",
"filter": {
"allCategories": "All Categories"
},
"actions": {
"install": "Install Template",
"delete": "Delete Template",
"saveAsTemplate": "Save as Template"
},
"loading": "Loading templates...",
"empty": {
"title": "No Templates Found",
"message": "Create your first template or install templates from the community",
"createFirst": "Create Your First Template"
},
"saveDialog": {
"title": "Save as Template",
"name": "Template Name",
"namePlaceholder": "e.g., My Python MCP Server",
"category": "Category",
"categoryPlaceholder": "Select a category",
"description": "Description",
"descriptionPlaceholder": "Brief description of what this template does...",
"validation": {
"nameRequired": "Template name is required"
},
"save": "Save Template",
"cancel": "Cancel"
},
"deleteDialog": {
"title": "Delete Template",
"message": "Are you sure you want to delete the template \"{name}\"? This action cannot be undone.",
"delete": "Delete",
"deleting": "Deleting...",
"cancel": "Cancel"
},
"categories": {
"stdio": "STDIO",
"sse": "SSE",
"language": "Language",
"official": "Official",
"custom": "Custom"
},
"feedback": {
"installSuccess": "Template installed successfully",
"installError": "Failed to install template",
"deleteSuccess": "Template deleted successfully",
"deleteError": "Failed to delete template",
"saveSuccess": "Template saved successfully",
"saveError": "Failed to save template"
}
},
"crossCli": {
"button": "Cross-CLI Copy",
"title": "Copy MCP Servers Between CLIs",
"selectServers": "Select servers to copy from {source}",
"selectServersHint": "Choose servers to copy. Configuration will be transformed for the target CLI format.",
"noServers": "No servers available in current configuration",
"selectedCount": "{count} server(s) selected",
"copying": "Copying...",
"copyButton": "Copy to {target}"
},
"allProjects": {
"title": "All Projects",
"name": "Project Name",
"servers": "Servers",
"lastModified": "Last Modified",
"actions": "Actions",
"current": "Current",
"openNewWindow": "Open in new window",
"empty": "No projects found",
"summary": "Showing {count} project(s)"
},
"otherProjects": {
"title": "Other Projects",
"description": "Discover and import MCP servers from your other projects",
"selectProject": "Select a project",
"selectProjectPlaceholder": "Choose a project...",
"noProjects": "No other projects available",
"noServers": "No MCP servers found in {project}",
"import": "Import",
"hint": "Imported servers will be added to your current project configuration"
},
"installCmd": {
"title": "Installation Command",
"cliCommand": "CLI Command",
"cliCommandHint": "Run this command in your terminal to install the server",
"jsonConfig": "JSON Configuration",
"jsonConfigHint": "Add this to your {filename} file",
"envVars": "Environment Variables",
"steps": "Installation Steps",
"step1": "Run the CLI command above in your terminal",
"step2": "Or manually add the JSON config to your configuration file",
"step3": "Restart your CLI to load the new server"
},
"enterprise": {
"label": "Enterprise",
"tooltip": "Enterprise MCP server"
}
}

View File

@@ -143,6 +143,18 @@
"empty": "No nodes available",
"clear": "Clear all"
},
"contextAssembler": {
"title": "Context Template",
"helpTitle": "Context Assembly Syntax",
"helpSyntax1": "Reference node output: {{node:node-id}}",
"helpSyntax2": "Reference variable: {{var:variableName}}",
"helpSyntax3": "Combine multiple sources in custom format",
"addNode": "Add Node Reference",
"selectNode": "Select a node...",
"addVariable": "Add Variable Reference",
"selectVariable": "Select a variable...",
"manualEdit": "Custom Template (use {{node:id}} or {{var:name}})"
},
"propertyPanel": {
"title": "Properties",
"open": "Open properties panel",
@@ -160,7 +172,9 @@
"variableName": "variableName",
"condition": "e.g., result.success === true",
"trueLabel": "True",
"falseLabel": "False"
"falseLabel": "False",
"contextTemplate": "Template with {variable} placeholders",
"promptText": "Enter your prompt here..."
},
"labels": {
"label": "Label",
@@ -179,7 +193,13 @@
"trueLabel": "True Label",
"falseLabel": "False Label",
"joinMode": "Join Mode",
"failFast": "Fail fast (stop all branches on first error)"
"failFast": "Fail fast (stop all branches on first error)",
"tool": "CLI Tool",
"mode": "Mode",
"promptType": "Prompt Type",
"sourceNodes": "Source Nodes",
"contextTemplate": "Context Template",
"promptText": "Prompt Text"
},
"options": {
"modeMainprocess": "Main Process",
@@ -195,7 +215,18 @@
"operationMove": "Move",
"joinModeAll": "Wait for all branches",
"joinModeAny": "Complete when any branch finishes",
"joinModeNone": "No synchronization"
"joinModeNone": "No synchronization",
"toolGemini": "Gemini",
"toolQwen": "Qwen",
"toolCodex": "Codex",
"modeAnalysis": "Analysis",
"modeWrite": "Write",
"modeReview": "Review",
"promptTypeOrganize": "Organize",
"promptTypeRefine": "Refine",
"promptTypeSummarize": "Summarize",
"promptTypeTransform": "Transform",
"promptTypeCustom": "Custom"
}
}
}

View File

@@ -1,6 +1,11 @@
{
"title": "MCP 服务器",
"description": "管理模型上下文协议 (MCP) 服务器以实现跨 CLI 集成",
"tabs": {
"templates": "模板",
"servers": "服务器",
"crossCli": "跨 CLI"
},
"mode": {
"claude": "Claude",
"codex": "Codex"
@@ -25,7 +30,26 @@
"codex": {
"configPath": "配置路径",
"readOnly": "只读",
"readOnlyNotice": "Codex MCP 服务器通过 config.toml 管理,无法在此处编辑。"
"readOnlyNotice": "Codex MCP 服务器通过 config.toml 管理,无法在此处编辑。",
"editable": "可编辑",
"editableNotice": "此服务器可以编辑。更改将保存到 config.toml。",
"deleteConfirm": {
"title": "删除服务器 \"{name}\"",
"description": "这将从您的 Codex config.toml 文件中删除 \"{name}\"。此操作无法撤销。",
"confirm": "删除服务器",
"cancel": "取消",
"deleting": "删除中..."
}
},
"windows": {
"title": "Windows 兼容性",
"description": "某些 MCP 服务器命令需要 Windows 特定配置才能正确执行。",
"missingCount": "缺少 {count} 个命令",
"checking": "正在检查 Windows 兼容性...",
"fixing": "正在应用 Windows 兼容性修复...",
"autoFix": "自动修复命令",
"autoFixHint": "这将在命令前添加 'cmd /c' 前缀以提高 Windows 兼容性。",
"install": "下载"
},
"filters": {
"all": "全部",
@@ -126,5 +150,148 @@
"saveConfig": "保存配置",
"saving": "保存中..."
}
},
"recommended": {
"title": "推荐服务器",
"description": "一键快速安装热门 MCP 服务器",
"servers": {
"ace": {
"name": "ACE 工具",
"description": "高级代码搜索和上下文引擎,用于智能代码发现",
"command": "mcp__ace-tool__search_context",
"install": "安装 ACE",
"installing": "安装中..."
},
"chrome": {
"name": "Chrome 开发者工具",
"description": "浏览器自动化和调试工具,用于 Web 开发",
"command": "mcp__chrome-devtools",
"install": "安装 Chrome",
"installing": "安装中..."
},
"exa": {
"name": "Exa 搜索",
"description": "AI 驱动的网络搜索,支持实时抓取",
"command": "mcp__exa__search",
"install": "安装 Exa",
"installing": "安装中..."
}
},
"actions": {
"install": "安装",
"installing": "安装中...",
"installed": "已安装",
"viewAll": "查看全部服务器"
}
},
"configType": {
"label": "配置格式",
"mcpJson": ".mcp.json",
"claudeJson": ".claude.json",
"switchWarning": "切换配置格式不会迁移现有服务器。您需要在新格式中重新配置服务器。",
"switchConfirm": "切换配置格式",
"switchCancel": "保持当前格式"
},
"templates": {
"title": "模板",
"description": "可重用的 MCP 服务器配置模板",
"searchPlaceholder": "按名称或描述搜索模板...",
"filter": {
"allCategories": "所有类别"
},
"actions": {
"install": "安装模板",
"delete": "删除模板",
"saveAsTemplate": "保存为模板"
},
"loading": "正在加载模板...",
"empty": {
"title": "未找到模板",
"message": "创建您的第一个模板或从社区安装模板",
"createFirst": "创建第一个模板"
},
"saveDialog": {
"title": "保存为模板",
"name": "模板名称",
"namePlaceholder": "例如:我的 Python MCP 服务器",
"category": "类别",
"categoryPlaceholder": "选择一个类别",
"description": "描述",
"descriptionPlaceholder": "简要描述此模板的用途...",
"validation": {
"nameRequired": "模板名称不能为空"
},
"save": "保存模板",
"cancel": "取消"
},
"deleteDialog": {
"title": "删除模板",
"message": "确定要删除模板 \"{name}\" 吗?此操作无法撤销。",
"delete": "删除",
"deleting": "删除中...",
"cancel": "取消"
},
"categories": {
"stdio": "STDIO",
"sse": "SSE",
"language": "语言",
"official": "官方",
"custom": "自定义"
},
"feedback": {
"installSuccess": "模板安装成功",
"installError": "模板安装失败",
"deleteSuccess": "模板删除成功",
"deleteError": "模板删除失败",
"saveSuccess": "模板保存成功",
"saveError": "模板保存失败"
}
},
"crossCli": {
"button": "跨 CLI 复制",
"title": "在 CLI 之间复制 MCP 服务器",
"selectServers": "选择要从 {source} 复制的服务器",
"selectServersHint": "选择要复制的服务器。配置将转换为目标 CLI 格式。",
"noServers": "当前配置中没有可用的服务器",
"selectedCount": "已选择 {count} 个服务器",
"copying": "复制中...",
"copyButton": "复制到 {target}"
},
"allProjects": {
"title": "所有项目",
"name": "项目名称",
"servers": "服务器",
"lastModified": "最后修改",
"actions": "操作",
"current": "当前",
"openNewWindow": "在新窗口中打开",
"empty": "未找到项目",
"summary": "显示 {count} 个项目"
},
"otherProjects": {
"title": "其他项目",
"description": "从您的其他项目发现并导入 MCP 服务器",
"selectProject": "选择一个项目",
"selectProjectPlaceholder": "选择一个项目...",
"noProjects": "没有其他可用的项目",
"noServers": "在 {project} 中未找到 MCP 服务器",
"import": "导入",
"hint": "导入的服务器将添加到您的当前项目配置中"
},
"installCmd": {
"title": "安装命令",
"cliCommand": "CLI 命令",
"cliCommandHint": "在终端中运行此命令以安装服务器",
"jsonConfig": "JSON 配置",
"jsonConfigHint": "将此添加到您的 {filename} 文件中",
"envVars": "环境变量",
"steps": "安装步骤",
"step1": "在您的终端中运行上面的 CLI 命令",
"step2": "或将 JSON 配置手动添加到您的配置文件中",
"step3": "重启您的 CLI 以加载新服务器"
},
"enterprise": {
"label": "企业版",
"tooltip": "企业版 MCP 服务器"
}
}

View File

@@ -142,6 +142,18 @@
"empty": "没有可用的节点",
"clear": "清除全部"
},
"contextAssembler": {
"title": "上下文模板",
"helpTitle": "上下文组装语法",
"helpSyntax1": "引用节点输出: {{node:节点ID}}",
"helpSyntax2": "引用变量: {{var:变量名}}",
"helpSyntax3": "以自定义格式组合多个来源",
"addNode": "添加节点引用",
"selectNode": "选择节点...",
"addVariable": "添加变量引用",
"selectVariable": "选择变量...",
"manualEdit": "自定义模板 (使用 {{node:id}} 或 {{var:name}})"
},
"propertyPanel": {
"title": "属性",
"open": "打开属性面板",
@@ -159,7 +171,9 @@
"variableName": "变量名称",
"condition": "例如: result.success === true",
"trueLabel": "真",
"falseLabel": "假"
"falseLabel": "假",
"contextTemplate": "带有 {variable} 占位符的模板",
"promptText": "在此输入您的提示词..."
},
"labels": {
"label": "标签",
@@ -178,7 +192,13 @@
"trueLabel": "真标签",
"falseLabel": "假标签",
"joinMode": "加入模式",
"failFast": "快速失败 (首次错误时停止所有分支)"
"failFast": "快速失败 (首次错误时停止所有分支)",
"tool": "CLI 工具",
"mode": "模式",
"promptType": "提示词类型",
"sourceNodes": "源节点",
"contextTemplate": "上下文模板",
"promptText": "提示词文本"
},
"options": {
"modeMainprocess": "主进程",
@@ -194,7 +214,18 @@
"operationMove": "移动",
"joinModeAll": "等待所有分支",
"joinModeAny": "任一分支完成时完成",
"joinModeNone": "无同步"
"joinModeNone": "无同步",
"toolGemini": "Gemini",
"toolQwen": "Qwen",
"toolCodex": "Codex",
"modeAnalysis": "分析",
"modeWrite": "写入",
"modeReview": "审查",
"promptTypeOrganize": "组织",
"promptTypeRefine": "精炼",
"promptTypeSummarize": "总结",
"promptTypeTransform": "转换",
"promptTypeCustom": "自定义"
}
}
}

View File

@@ -1,8 +1,8 @@
// ========================================
// MCP Manager Page
// ========================================
// Manage MCP servers (Model Context Protocol) with project/global scope switching
// Supports both Claude and Codex CLI modes
// Manage MCP servers (Model Context Protocol) with tabbed interface
// Supports Templates, Servers, and Cross-CLI tabs
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -27,15 +27,24 @@ import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { McpServerDialog } from '@/components/mcp/McpServerDialog';
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
import { CodexMcpCard } from '@/components/mcp/CodexMcpCard';
import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard';
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
import { McpTemplatesSection } from '@/components/mcp/McpTemplatesSection';
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
import { ConfigTypeToggle } from '@/components/mcp/ConfigTypeToggle';
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
import { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useMcpServers, useMcpServerMutations } from '@/hooks';
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
updateCcwConfig,
codexRemoveServer,
codexToggleServer,
type McpServer,
type CodexMcpServer,
type CcwMcpConfig,
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -190,6 +199,7 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
export function McpManagerPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<'templates' | 'servers' | 'cross-cli'>('servers');
const [searchQuery, setSearchQuery] = useState('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
@@ -197,6 +207,7 @@ export function McpManagerPage() {
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
const [cliMode, setCliMode] = useState<CliMode>('claude');
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
const [configType, setConfigType] = useState<'mcp-json' | 'claude-json'>('mcp-json');
const {
servers,
@@ -317,6 +328,44 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
};
// Template handlers
const handleInstallTemplate = (template: any) => {
setEditingServer({
name: template.name,
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env,
scope: 'project',
enabled: true,
});
setDialogOpen(true);
};
const handleSaveAsTemplate = (serverName: string, config: { command: string; args: string[] }) => {
// This would open a dialog to save current server as template
// For now, just log it
console.log('Save as template:', serverName, config);
};
// Codex MCP handlers
const handleCodexRemove = async (serverName: string) => {
try {
await codexRemoveServer(serverName);
codexQuery.refetch();
} catch (error) {
console.error('Failed to remove Codex MCP server:', error);
}
};
const handleCodexToggle = async (serverName: string, enabled: boolean) => {
try {
await codexToggleServer(serverName, enabled);
codexQuery.refetch();
} catch (error) {
console.error('Failed to toggle Codex MCP server:', error);
}
};
// Filter servers by search query
const filteredServers = servers.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -372,7 +421,48 @@ export function McpManagerPage() {
codexConfigPath={codexConfigPath}
/>
{/* Stats Cards - Claude mode only */}
{/* Tabbed Interface */}
<TabsNavigation
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'templates' | 'servers' | 'cross-cli')}
tabs={[
{ value: 'templates', label: formatMessage({ id: 'mcp.tabs.templates' }) },
{ value: 'servers', label: formatMessage({ id: 'mcp.tabs.servers' }) },
{ value: 'cross-cli', label: formatMessage({ id: 'mcp.tabs.crossCli' }) },
]}
/>
{/* Tab Content: Templates */}
{activeTab === 'templates' && (
<div className="mt-4">
<McpTemplatesSection
onInstallTemplate={handleInstallTemplate}
onSaveAsTemplate={handleSaveAsTemplate}
/>
</div>
)}
{/* Tab Content: Servers */}
{activeTab === 'servers' && (
<div className="mt-4 space-y-4">
{/* Windows Compatibility Warning */}
<WindowsCompatibilityWarning />
{/* Recommended MCP Servers */}
{cliMode === 'claude' && (
<RecommendedMcpSection onInstallComplete={() => refetch()} />
)}
{/* Config Type Toggle */}
{cliMode === 'claude' && (
<ConfigTypeToggle
currentType={configType}
onTypeChange={setConfigType}
existingServersCount={totalCount}
/>
)}
{/* Stats Cards - Claude mode only */}
{cliMode === 'claude' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
@@ -492,12 +582,15 @@ export function McpManagerPage() {
<div className="space-y-3">
{currentServers.map((server) => (
cliMode === 'codex' ? (
<CodexMcpCard
<CodexMcpEditableCard
key={server.name}
server={server as CodexMcpServer}
server={server as McpServer}
enabled={server.enabled}
isExpanded={currentExpanded.has(server.name)}
onToggleExpand={() => currentToggleExpand(server.name)}
isEditable={true}
onRemove={handleCodexRemove}
onToggle={handleCodexToggle}
/>
) : (
<McpServerCard
@@ -513,8 +606,46 @@ export function McpManagerPage() {
))}
</div>
)}
</div>
)}
{/* Add/Edit Dialog - Claude mode only */}
{/* Tab Content: Cross-CLI */}
{activeTab === 'cross-cli' && (
<div className="mt-4 space-y-4">
{/* Cross-CLI Copy Button */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex-1">
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.crossCli.title' })}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
</p>
</div>
<CrossCliCopyButton
currentMode={cliMode}
onSuccess={() => refetch()}
/>
</div>
{/* All Projects Table */}
<AllProjectsTable
maxProjects={10}
onProjectClick={(path) => console.log('Open project:', path)}
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
/>
{/* Other Projects Section */}
<OtherProjectsSection
onImportSuccess={(serverName, sourceProject) => {
console.log('Imported server:', serverName, 'from:', sourceProject);
refetch();
}}
/>
</div>
)}
{/* Add/Edit Dialog - Claude mode only (shared across tabs) */}
{cliMode === 'claude' && (
<McpServerDialog
mode={editingServer ? 'edit' : 'add'}

View File

@@ -171,6 +171,10 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
return '#f59e0b'; // amber-500
case 'parallel':
return '#a855f7'; // purple-500
case 'cli-command':
return '#f59e0b'; // amber-500
case 'prompt':
return '#a855f7'; // purple-500
default:
return '#6b7280'; // gray-500
}

View File

@@ -18,6 +18,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Color mapping for node types
@@ -26,6 +28,8 @@ const nodeColors: Record<FlowNodeType, string> = {
'file-operation': 'bg-green-500 hover:bg-green-600',
conditional: 'bg-amber-500 hover:bg-amber-600',
parallel: 'bg-purple-500 hover:bg-purple-600',
'cli-command': 'bg-amber-500 hover:bg-amber-600',
prompt: 'bg-purple-500 hover:bg-purple-600',
};
const nodeBorderColors: Record<FlowNodeType, string> = {
@@ -33,6 +37,8 @@ const nodeBorderColors: Record<FlowNodeType, string> = {
'file-operation': 'border-green-500',
conditional: 'border-amber-500',
parallel: 'border-purple-500',
'cli-command': 'border-amber-500',
prompt: 'border-purple-500',
};
interface NodePaletteProps {

View File

@@ -9,6 +9,8 @@ import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lu
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { MultiNodeSelector, type NodeOption } from '@/components/ui/MultiNodeSelector';
import { ContextAssembler } from '@/components/ui/ContextAssembler';
import { useFlowStore } from '@/stores';
import type {
FlowNodeType,
@@ -16,9 +18,55 @@ import type {
FileOperationNodeData,
ConditionalNodeData,
ParallelNodeData,
CliCommandNodeData,
PromptNodeData,
NodeData,
} from '@/types/flow';
// ========== Common Form Field Components ==========
interface LabelInputProps {
value: string;
onChange: (value: string) => void;
}
function LabelInput({ value, onChange }: LabelInputProps) {
const { formatMessage } = useIntl();
return (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}
</label>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
);
}
interface OutputVariableInputProps {
value?: string;
onChange: (value?: string) => void;
}
function OutputVariableInput({ value, onChange }: OutputVariableInputProps) {
const { formatMessage } = useIntl();
return (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}
</label>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
);
}
interface PropertyPanelProps {
className?: string;
}
@@ -29,6 +77,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Slash Command Property Editor
@@ -43,14 +93,7 @@ function SlashCommandProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
@@ -118,14 +161,7 @@ function SlashCommandProperties({
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -142,14 +178,7 @@ function FileOperationProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.operation' })}</label>
@@ -205,14 +234,7 @@ function FileOperationProperties({
</div>
)}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
<div className="flex items-center gap-2">
<input
@@ -242,14 +264,7 @@ function ConditionalProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.condition' })}</label>
@@ -280,14 +295,7 @@ function ConditionalProperties({
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -304,14 +312,7 @@ function ParallelProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.joinMode' })}</label>
@@ -353,14 +354,174 @@ function ParallelProperties({
</label>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
// CLI Command Property Editor
function CliCommandProperties({
data,
onChange,
}: {
data: CliCommandNodeData;
onChange: (updates: Partial<CliCommandNodeData>) => void;
}) {
const { formatMessage } = useIntl();
return (
<div className="space-y-4">
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
value={data.command || ''}
onChange={(e) => onChange({ command: e.target.value })}
placeholder="PURPOSE: ... TASK: ..."
className="font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.arguments' })}</label>
<Input
value={data.args || ''}
onChange={(e) => onChange({ args: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandArgs' })}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.tool' })}</label>
<select
value={data.tool || 'gemini'}
onChange={(e) => onChange({ tool: e.target.value as 'gemini' | 'qwen' | 'codex' })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="gemini">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolGemini' })}</option>
<option value="qwen">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolQwen' })}</option>
<option value="codex">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolCodex' })}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}</label>
<select
value={data.mode || 'analysis'}
onChange={(e) => onChange({ mode: e.target.value as 'analysis' | 'write' | 'review' })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
<option value="review">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeReview' })}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.timeout' })}</label>
<Input
type="number"
value={data.execution?.timeout || ''}
onChange={(e) =>
onChange({
execution: {
...data.execution,
timeout: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
// Prompt Property Editor
function PromptProperties({
data,
onChange,
}: {
data: PromptNodeData;
onChange: (updates: Partial<PromptNodeData>) => void;
}) {
const { formatMessage } = useIntl();
const nodes = useFlowStore((state) => state.nodes);
// Build available nodes list for MultiNodeSelector and ContextAssembler
const availableNodes: NodeOption[] = nodes
.filter((n) => n.id !== useFlowStore.getState().selectedNodeId) // Exclude current node
.map((n) => ({
id: n.id,
label: n.data?.label || n.id,
type: n.type,
}));
// Build available variables list from nodes with outputVariable
const availableVariables = nodes
.filter((n) => n.data?.outputVariable)
.map((n) => n.data?.outputVariable as string)
.filter(Boolean);
return (
<div className="space-y-4">
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptType' })}</label>
<select
value={data.promptType || 'custom'}
onChange={(e) => onChange({ promptType: e.target.value as PromptNodeData['promptType'] })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="organize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeOrganize' })}</option>
<option value="refine">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeRefine' })}</option>
<option value="summarize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeSummarize' })}</option>
<option value="transform">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeTransform' })}</option>
<option value="custom">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeCustom' })}</option>
</select>
</div>
{/* MultiNodeSelector for source nodes */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.sourceNodes' })}</label>
<MultiNodeSelector
availableNodes={availableNodes}
selectedNodes={data.sourceNodes || []}
onChange={(selectedIds) => onChange({ sourceNodes: selectedIds })}
placeholder={formatMessage({ id: 'orchestrator.multiNodeSelector.empty' })}
/>
</div>
{/* ContextAssembler for context template management */}
<div>
<ContextAssembler
value={data.contextTemplate || ''}
onChange={(value) => onChange({ contextTemplate: value })}
availableNodes={nodes.map((n) => ({
id: n.id,
label: n.data?.label || n.id,
type: n.type,
outputVariable: n.data?.outputVariable,
}))}
availableVariables={availableVariables}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptText' })}</label>
<textarea
value={data.promptText || ''}
onChange={(e) => onChange({ promptText: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.promptText' })}
className="w-full h-32 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -489,6 +650,18 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
onChange={handleChange}
/>
)}
{nodeType === 'cli-command' && (
<CliCommandProperties
data={selectedNode.data as CliCommandNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'prompt' && (
<PromptProperties
data={selectedNode.data as PromptNodeData}
onChange={handleChange}
/>
)}
</div>
{/* Delete Button */}

View File

@@ -0,0 +1,112 @@
// ========================================
// CLI Command Node Component
// ========================================
// Custom node for executing CLI tools with AI models
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Terminal } from 'lucide-react';
import type { CliCommandNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface CliCommandNodeProps {
data: CliCommandNodeData;
selected?: boolean;
}
// Mode badge styling
const MODE_STYLES = {
analysis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
write: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
};
// Tool badge styling
const TOOL_STYLES = {
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 border border-blue-200 dark:border-blue-800',
qwen: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400 border border-green-200 dark:border-green-800',
codex: 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400 border border-purple-200 dark:border-purple-800',
};
export const CliCommandNode = memo(({ data, selected }: CliCommandNodeProps) => {
const mode = data.mode || 'analysis';
const tool = data.tool || 'gemini';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="amber"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500 text-white rounded-t-md">
<Terminal className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'CLI Command'}
</span>
{/* Tool badge */}
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded bg-white/20', TOOL_STYLES[tool])}>
{tool}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Command name */}
{data.command && (
<div className="flex items-center gap-1">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-foreground">
ccw cli {data.command}
</span>
</div>
)}
{/* Arguments (truncated) */}
{data.args && (
<div className="text-xs text-muted-foreground truncate max-w-[160px]">
<span className="text-foreground/70 font-mono">{data.args}</span>
</div>
)}
{/* Mode badge */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">Mode:</span>
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[mode])}>
{mode}
</span>
</div>
{/* Output variable indicator */}
{data.outputVariable && (
<div className="text-[10px] text-muted-foreground">
{'->'} {data.outputVariable}
</div>
)}
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
{data.executionError}
</div>
)}
</div>
{/* Output Handle */}
<Handle
type="source"
position={Position.Bottom}
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
CliCommandNode.displayName = 'CliCommandNode';

View File

@@ -0,0 +1,120 @@
// ========================================
// Prompt Node Component
// ========================================
// Custom node for constructing AI prompts with context
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { FileText } from 'lucide-react';
import type { PromptNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface PromptNodeProps {
data: PromptNodeData;
selected?: boolean;
}
// Prompt type badge styling
const PROMPT_TYPE_STYLES = {
organize: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
refine: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
summarize: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
transform: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
custom: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
};
// Prompt type labels for display
const PROMPT_TYPE_LABELS: Record<PromptNodeData['promptType'], string> = {
organize: 'Organize',
refine: 'Refine',
summarize: 'Summarize',
transform: 'Transform',
custom: 'Custom',
};
export const PromptNode = memo(({ data, selected }: PromptNodeProps) => {
const promptType = data.promptType || 'custom';
// Truncate prompt text for display
const displayPrompt = data.promptText
? data.promptText.length > 40
? data.promptText.slice(0, 37) + '...'
: data.promptText
: 'No prompt';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="purple"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500 text-white rounded-t-md">
<FileText className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'Prompt'}
</span>
{/* Prompt type badge */}
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', PROMPT_TYPE_STYLES[promptType])}>
{PROMPT_TYPE_LABELS[promptType]}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Prompt text preview */}
<div
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
title={data.promptText}
>
{displayPrompt}
</div>
{/* Source nodes count */}
{data.sourceNodes && data.sourceNodes.length > 0 && (
<div className="text-[10px] text-muted-foreground">
Sources: {data.sourceNodes.length} node{data.sourceNodes.length !== 1 ? 's' : ''}
</div>
)}
{/* Context template indicator */}
{data.contextTemplate && (
<div className="text-[10px] text-muted-foreground truncate max-w-[160px]" title={data.contextTemplate}>
Template: {data.contextTemplate.slice(0, 20)}...
</div>
)}
{/* Output variable indicator */}
{data.outputVariable && (
<div className="text-[10px] text-muted-foreground">
{'->'} {data.outputVariable}
</div>
)}
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
{data.executionError}
</div>
)}
</div>
{/* Output Handle */}
<Handle
type="source"
position={Position.Bottom}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
PromptNode.displayName = 'PromptNode';

View File

@@ -10,12 +10,16 @@ export { SlashCommandNode } from './SlashCommandNode';
export { FileOperationNode } from './FileOperationNode';
export { ConditionalNode } from './ConditionalNode';
export { ParallelNode } from './ParallelNode';
export { CliCommandNode } from './CliCommandNode';
export { PromptNode } from './PromptNode';
// Node types map for React Flow registration
import { SlashCommandNode } from './SlashCommandNode';
import { FileOperationNode } from './FileOperationNode';
import { ConditionalNode } from './ConditionalNode';
import { ParallelNode } from './ParallelNode';
import { CliCommandNode } from './CliCommandNode';
import { PromptNode } from './PromptNode';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const nodeTypes: Record<string, any> = {
@@ -23,4 +27,6 @@ export const nodeTypes: Record<string, any> = {
'file-operation': FileOperationNode,
conditional: ConditionalNode,
parallel: ParallelNode,
'cli-command': CliCommandNode,
prompt: PromptNode,
};

View File

@@ -7,7 +7,7 @@ import type { Node, Edge } from '@xyflow/react';
// ========== Node Types ==========
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel';
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel' | 'cli-command' | 'prompt';
// Execution status for nodes during workflow execution
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
@@ -41,7 +41,6 @@ export interface FileOperationNodeData extends BaseNodeData {
content?: string;
destinationPath?: string;
encoding?: 'utf8' | 'ascii' | 'base64';
outputVariable?: string;
addToContext?: boolean;
}
@@ -60,12 +59,33 @@ export interface ParallelNodeData extends BaseNodeData {
failFast?: boolean;
}
// CLI Command Node Data
export interface CliCommandNodeData extends BaseNodeData {
command: string;
args?: string;
tool: 'gemini' | 'qwen' | 'codex';
mode: 'analysis' | 'write' | 'review';
execution?: {
timeout?: number;
};
}
// Prompt Node Data
export interface PromptNodeData extends BaseNodeData {
promptType: 'organize' | 'refine' | 'summarize' | 'transform' | 'custom';
sourceNodes: string[];
contextTemplate?: string;
promptText: string;
}
// Union type for all node data
export type NodeData =
| SlashCommandNodeData
| FileOperationNodeData
| ConditionalNodeData
| ParallelNodeData;
| ParallelNodeData
| CliCommandNodeData
| PromptNodeData;
// Extended Node type for React Flow
export type FlowNode = Node<NodeData, FlowNodeType>;
@@ -238,4 +258,32 @@ export const NODE_TYPE_CONFIGS: Record<FlowNodeType, NodeTypeConfig> = {
} as ParallelNodeData,
handles: { inputs: 1, outputs: 2 },
},
'cli-command': {
type: 'cli-command',
label: 'CLI Command',
description: 'Execute CLI tools with AI models',
icon: 'Terminal',
color: 'bg-amber-500',
defaultData: {
label: 'CLI Command',
command: '',
tool: 'gemini',
mode: 'analysis',
} as CliCommandNodeData,
handles: { inputs: 1, outputs: 1 },
},
prompt: {
type: 'prompt',
label: 'Prompt',
description: 'Construct AI prompts with context',
icon: 'FileText',
color: 'bg-purple-500',
defaultData: {
label: 'Prompt',
promptType: 'custom',
sourceNodes: [],
promptText: '',
} as PromptNodeData,
handles: { inputs: 1, outputs: 1 },
},
};

View File

@@ -805,3 +805,111 @@ export interface Suggestion {
/** Whether suggestion was applied */
applied?: boolean;
}
// ========================================
// MCP Template Types
// ========================================
/**
* MCP Server Template for reusable configurations
* Matches backend schema from mcp-templates-db.ts
*/
export interface McpTemplate {
/** Template ID (database primary key) */
id?: number;
/** Unique template name */
name: string;
/** Template description */
description?: string;
/** Server command configuration */
serverConfig: {
/** Command to run */
command: string;
/** Command arguments */
args?: string[];
/** Environment variables */
env?: Record<string, string>;
};
/** Optional tags for categorization */
tags?: string[];
/** Category for grouping */
category?: string;
/** Creation timestamp */
createdAt?: number;
/** Last update timestamp */
updatedAt?: number;
}
/**
* MCP Template category response
*/
export interface McpTemplateCategory {
/** Category name */
name: string;
/** Number of templates in category */
count: number;
}
/**
* MCP Template installation request
*/
export interface McpTemplateInstallRequest {
/** Template name to install */
templateName: string;
/** Target project path (required for project scope) */
projectPath?: string;
/** Installation scope */
scope: 'global' | 'project';
/** Configuration type ('mcp' for .mcp.json, 'claude' for .claude.json) */
configType?: 'mcp' | 'claude';
}
/**
* All projects overview response
*/
export interface AllProjectsResponse {
/** List of all project paths */
projects: string[];
/** Current active project path */
currentProject?: string;
}
/**
* Other projects' MCP servers response
*/
export interface OtherProjectsServersResponse {
/** Map of project path to their MCP servers */
servers: Record<string, Array<{
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
enabled: boolean;
}>>;
}
/**
* Cross-CLI MCP server copy request
*/
export interface CrossCliCopyRequest {
/** Source CLI (claude or codex) */
source: 'claude' | 'codex';
/** Target CLI (claude or codex) */
target: 'claude' | 'codex';
/** Server names to copy */
serverNames: string[];
/** Target project path (optional, defaults to current) */
projectPath?: string;
}
/**
* Cross-CLI copy response
*/
export interface CrossCliCopyResponse {
/** Copy success status */
success: boolean;
/** Successfully copied servers */
copied: string[];
/** Failed servers with error messages */
failed: Array<{ name: string; error: string }>;
}