From 4e0334019975cc496d78b7bdae553523985c5baf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 21:04:48 +0000 Subject: [PATCH] feat: add MCP server allocation on demand with dual pool support Adds a complete MCP server lifecycle management system alongside the existing sandbox infrastructure. MCP servers use their own Docker image, warm pool, and generous lifecycle timeouts (60m idle, 8h max vs 5m/15m for sandboxes). Backend: - New McpContainerService with create, allocate, start/arm, proxy, status, stop, delete - Dual warm pool support in PoolManager (sandbox + MCP, each with own target size) - Container-type labels (sandbox/mcp-server) for clean separation - Type-aware cleanup service with per-type idle/lifetime thresholds - New /api/mcp-servers/* endpoints mirroring sandbox patterns - McpServerImage, McpPodNamePrefix, McpWarmPoolSize config Frontend: - Top-level navigation tabs (Sandboxes / MCP Servers) in AppLayout - McpServerManager with tabbed interface (Dashboard + per-server tabs) - McpServerCreator with allocate/create, pool status, SSE streaming - McpServerDetail with start/arm form, JSON-RPC proxy panel, status polling https://claude.ai/code/session_018N7E8gxNjLSL5xPLDt94ws --- docker-compose.yml | 6 + frontend/src/App.tsx | 11 +- frontend/src/components/layout/AppLayout.tsx | 39 +- .../src/components/mcp/McpServerCreator.tsx | 542 ++++++++++++++ .../src/components/mcp/McpServerDetail.tsx | 508 +++++++++++++ .../src/components/mcp/McpServerManager.tsx | 96 +++ frontend/src/types/api.ts | 71 ++ .../Configuration/KataContainerManager.cs | 21 + .../Endpoints/McpServerEndpoints.cs | 308 ++++++++ .../Models/CreateMcpServerRequest.cs | 25 + .../Models/McpServerCreationEvent.cs | 20 + .../Models/McpServerInfo.cs | 26 + .../Models/McpStartRequest.cs | 12 + .../Models/McpStatusResponse.cs | 9 + src/DonkeyWork.CodeSandbox.Manager/Program.cs | 3 + .../Background/ContainerCleanupService.cs | 13 +- .../Container/KataContainerService.cs | 4 + .../Services/Mcp/IMcpContainerService.cs | 40 + .../Services/Mcp/McpContainerService.cs | 681 ++++++++++++++++++ .../Services/Pool/PoolManager.cs | 134 ++-- .../appsettings.json | 7 +- 21 files changed, 2522 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/mcp/McpServerCreator.tsx create mode 100644 frontend/src/components/mcp/McpServerDetail.tsx create mode 100644 frontend/src/components/mcp/McpServerManager.tsx create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Endpoints/McpServerEndpoints.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Models/CreateMcpServerRequest.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Models/McpServerCreationEvent.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Models/McpServerInfo.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Models/McpStartRequest.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Models/McpStatusResponse.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Services/Mcp/IMcpContainerService.cs create mode 100644 src/DonkeyWork.CodeSandbox.Manager/Services/Mcp/McpContainerService.cs 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" + /> +
+ + +
+
+ +
+ +