diff --git a/docker-compose.yml b/docker-compose.yml index 2737fa4..aa1d179 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,12 @@ services: - KataContainerManager__PodNamePrefix=kata-sandbox - KataContainerManager__CleanupCompletedPods=true - KataContainerManager__PodReadyTimeoutSeconds=90 + # MCP Server Configuration + - KataContainerManager__McpServerImage=ghcr.io/andyjmorgan/donkeywork-codesandbox-mcpserver:latest + - KataContainerManager__McpPodNamePrefix=kata-mcp + - KataContainerManager__McpWarmPoolSize=5 + - KataContainerManager__McpIdleTimeoutMinutes=60 + - KataContainerManager__McpMaxContainerLifetimeMinutes=480 # Serilog Configuration - Serilog__MinimumLevel__Default=Information - Serilog__MinimumLevel__Override__Microsoft=Warning diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6ebf14..6155b41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,17 @@ +import { useState } from 'react' import { AppLayout } from '@/components/layout/AppLayout' import { SandboxManager } from '@/components/sandbox/SandboxManager' +import { McpServerManager } from '@/components/mcp/McpServerManager' + +export type ActiveSection = 'sandboxes' | 'mcp-servers' function App() { + const [activeSection, setActiveSection] = useState('sandboxes') + return ( - - + + {activeSection === 'sandboxes' && } + {activeSection === 'mcp-servers' && } ) } diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 7cb75f9..f91736a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,13 +1,17 @@ import type { ReactNode } from 'react' +import type { ActiveSection } from '@/App' import { ThemeToggle } from './ThemeToggle' -import { Github } from 'lucide-react' +import { Github, Box, Server } from 'lucide-react' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' interface AppLayoutProps { children: ReactNode + activeSection: ActiveSection + onSectionChange: (section: ActiveSection) => void } -export function AppLayout({ children }: AppLayoutProps) { +export function AppLayout({ children, activeSection, onSectionChange }: AppLayoutProps) { return (
{/* Header */} @@ -19,8 +23,37 @@ export function AppLayout({ children }: AppLayoutProps) { alt="DonkeyWork Logo" className="w-7 h-7" /> -

Sandbox Manager

+

Sandbox Manager

+ + {/* Navigation */} + +
+ + + )} + {(state.status === 'success' || state.status === 'error') && ( + + )} +
+ + + {state.status !== 'idle' && ( +
+
+
+ {state.status === 'creating' && ( + + )} + {state.status === 'success' && ( + + )} + {state.status === 'error' && ( + + )} + {state.message} +
+ +
+ + + + Output + Debug + + +
+ {state.events.length === 0 && state.status === 'success' ? ( +
+
+ + MCP server allocated from warm pool in {state.elapsedSeconds.toFixed(2)}s +
+ {state.containerInfo && ( +
+
Pod: {state.containerInfo.name}
+
IP: {state.containerInfo.podIP}
+
Node: {state.containerInfo.nodeName || 'N/A'}
+
+ )} +
+ ) : state.events.length === 0 ? ( +

Waiting for events...

+ ) : ( +
    + {state.events.map((event, idx) => ( +
  • + [{event.eventType}] + + {event.eventType === 'created' && `Pod ${event.podName} created (${event.phase})`} + {event.eventType === 'waiting' && event.message} + {event.eventType === 'ready' && `Container ready in ${event.elapsedSeconds.toFixed(1)}s`} + {event.eventType === 'failed' && event.reason} + {event.eventType === 'mcp_starting' && event.message} + {event.eventType === 'mcp_started' && `MCP process started in ${event.elapsedSeconds.toFixed(1)}s`} + {event.eventType === 'mcp_start_failed' && event.reason} + +
  • + ))} +
+ )} +
+
+ +
+ {state.rawMessages.length === 0 ? ( +

No raw messages yet...

+ ) : ( +
+                      {state.rawMessages.map((msg, idx) => (
+                        
+ {idx + 1}: {msg} +
+ ))} +
+ )} +
+
+
+
+ )} + + + ) +} diff --git a/frontend/src/components/mcp/McpServerDetail.tsx b/frontend/src/components/mcp/McpServerDetail.tsx new file mode 100644 index 0000000..11d64c5 --- /dev/null +++ b/frontend/src/components/mcp/McpServerDetail.tsx @@ -0,0 +1,508 @@ +import { useState, useCallback, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Server, Loader2, Trash2, Play, Square, Search, Send, ChevronDown, ChevronRight, CheckCircle, XCircle } from 'lucide-react' +import type { McpStatusResponse } from '@/types/api' +import type { McpCreationInfo } from './McpServerCreator' +import { cn } from '@/lib/utils' + +interface McpServerDetailProps { + serverId: string + creationInfo: McpCreationInfo + onDelete?: () => void +} + +interface ProxyExecution { + id: string + requestBody: string + responseBody: string | null + status: 'sending' | 'completed' | 'error' + startedAt: Date + isExpanded: boolean +} + +export function McpServerDetail({ serverId, creationInfo, onDelete }: McpServerDetailProps) { + const [mcpStatus, setMcpStatus] = useState(null) + const [isLoadingStatus, setIsLoadingStatus] = useState(false) + + // Start (arm) form state + const [launchCommand, setLaunchCommand] = useState('') + const [preExecScripts, setPreExecScripts] = useState('') + const [timeoutSeconds, setTimeoutSeconds] = useState(30) + const [isStarting, setIsStarting] = useState(false) + const [startError, setStartError] = useState(null) + const [isStopping, setIsStopping] = useState(false) + + // Proxy state + const [proxyBody, setProxyBody] = useState('{\n "jsonrpc": "2.0",\n "method": "tools/list",\n "id": 1\n}') + const [proxyExecutions, setProxyExecutions] = useState([]) + const [isSending, setIsSending] = useState(false) + + // Poll MCP status + const fetchStatus = useCallback(async () => { + setIsLoadingStatus(true) + try { + const response = await fetch(`/api/mcp-servers/${serverId}/status`) + if (response.ok) { + const data = await response.json() + setMcpStatus(data) + } + } catch (error) { + console.error('Failed to fetch MCP status:', error) + } finally { + setIsLoadingStatus(false) + } + }, [serverId]) + + useEffect(() => { + fetchStatus() + const interval = setInterval(fetchStatus, 5000) + return () => clearInterval(interval) + }, [fetchStatus]) + + const startMcpProcess = async () => { + if (!launchCommand.trim()) return + setIsStarting(true) + setStartError(null) + + try { + const response = await fetch(`/api/mcp-servers/${serverId}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + launchCommand: launchCommand.trim(), + preExecScripts: preExecScripts.trim() + ? preExecScripts.trim().split('\n').filter(s => s.trim()) + : [], + timeoutSeconds, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || `HTTP ${response.status}`) + } + + await fetchStatus() + } catch (error) { + setStartError(error instanceof Error ? error.message : 'Failed to start MCP process') + } finally { + setIsStarting(false) + } + } + + const stopMcpProcess = async () => { + setIsStopping(true) + try { + const response = await fetch(`/api/mcp-servers/${serverId}/process`, { + method: 'DELETE', + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + await fetchStatus() + } catch (error) { + alert(error instanceof Error ? error.message : 'Failed to stop MCP process') + } finally { + setIsStopping(false) + } + } + + const deleteMcpServer = async () => { + if (!confirm(`Are you sure you want to delete MCP server ${serverId}?`)) return + try { + const response = await fetch(`/api/mcp-servers/${serverId}`, { method: 'DELETE' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + onDelete?.() + } catch (error) { + alert(error instanceof Error ? error.message : 'Failed to delete MCP server') + } + } + + const sendProxyRequest = async () => { + if (!proxyBody.trim() || isSending) return + + const executionId = `proxy-${Date.now()}` + const newExec: ProxyExecution = { + id: executionId, + requestBody: proxyBody.trim(), + responseBody: null, + status: 'sending', + startedAt: new Date(), + isExpanded: true, + } + + setProxyExecutions(prev => [newExec, ...prev]) + setIsSending(true) + + try { + const response = await fetch(`/api/mcp-servers/${serverId}/proxy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: proxyBody.trim(), + }) + + const responseText = await response.text() + + let formattedResponse: string + try { + formattedResponse = JSON.stringify(JSON.parse(responseText), null, 2) + } catch { + formattedResponse = responseText + } + + setProxyExecutions(prev => prev.map(exec => + exec.id === executionId + ? { ...exec, responseBody: formattedResponse, status: response.ok ? 'completed' : 'error' } + : exec + )) + } catch (error) { + setProxyExecutions(prev => prev.map(exec => + exec.id === executionId + ? { ...exec, responseBody: error instanceof Error ? error.message : 'Request failed', status: 'error' } + : exec + )) + } finally { + setIsSending(false) + } + } + + const toggleProxyExpanded = (id: string) => { + setProxyExecutions(prev => prev.map(exec => + exec.id === id ? { ...exec, isExpanded: !exec.isExpanded } : exec + )) + } + + const isReady = mcpStatus?.state === 'Ready' + const isIdle = mcpStatus?.state === 'Idle' + const needsArming = isIdle || !mcpStatus + + const statusColor = (state?: string) => { + switch (state) { + case 'Ready': return 'bg-green-500/20 text-green-600 dark:text-green-400' + case 'Idle': return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400' + case 'Initializing': return 'bg-blue-500/20 text-blue-600 dark:text-blue-400' + case 'Error': return 'bg-red-500/20 text-red-600 dark:text-red-400' + case 'Disposed': return 'bg-gray-500/20 text-gray-600 dark:text-gray-400' + default: return 'bg-gray-500/20 text-gray-600 dark:text-gray-400' + } + } + + return ( +
+ {/* Header */} +
+
+ + MCP Server: + {serverId} + {mcpStatus && ( + + {mcpStatus.state} + + )} + {isLoadingStatus && } +
+
+ + {isReady && ( + + )} + +
+
+ + {/* Status Panel */} + {mcpStatus && ( +
+
+
+ State + + {mcpStatus.state} + +
+
+ Started At + {mcpStatus.startedAt ? new Date(mcpStatus.startedAt).toLocaleTimeString() : 'N/A'} +
+
+ Last Request + {mcpStatus.lastRequestAt ? new Date(mcpStatus.lastRequestAt).toLocaleTimeString() : 'N/A'} +
+
+ Error + {mcpStatus.error || 'None'} +
+
+
+ )} + + {/* Main Content - Tabs */} + + + Start / Arm + JSON-RPC Proxy + Info + + + {/* Start / Arm Tab */} + +
+
+

Start MCP Process

+

+ Provide a launch command to start the MCP stdio server inside the container. +

+
+ +
+
+ + setLaunchCommand(e.target.value)} + placeholder="npx -y @modelcontextprotocol/server-filesystem /home/user" + className="mt-1 font-mono" + /> +
+ + +
+
+ +
+ +