diff --git a/client/src/components/MCPHostComponent.tsx b/client/src/components/MCPHostComponent.tsx new file mode 100644 index 0000000..b19f3e7 --- /dev/null +++ b/client/src/components/MCPHostComponent.tsx @@ -0,0 +1,374 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Play, Square, Copy, Terminal, WifiOff, Wifi, Clock, Activity, ExternalLink } from 'lucide-react'; +import { MCPServer, MCPHostSession, MCPHostStartRequest, MCPHostStartResponse, MCPHostEvent } from '../types'; + +interface MCPHostComponentProps { + server: MCPServer; + className?: string; +} + +export function MCPHostComponent({ server, className = '' }: MCPHostComponentProps) { + const [session, setSession] = useState(null); + const [isStarting, setIsStarting] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + const [output, setOutput] = useState>([]); + const [showOutput, setShowOutput] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + + const eventSourceRef = useRef(null); + const outputRef = useRef(null); + + // Clean up event source on unmount + useEffect(() => { + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + }; + }, []); + + // Auto-scroll output to bottom + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output]); + + const addOutput = (type: 'stdout' | 'stderr' | 'info' | 'error', text: string) => { + setOutput(prev => [...prev, { type, text, timestamp: new Date() }]); + }; + + const connectToEventStream = (sessionId: string) => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + setConnectionStatus('connecting'); + const eventSource = new EventSource(`/v1/host/events/${sessionId}`); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setConnectionStatus('connected'); + addOutput('info', 'Connected to MCP server event stream'); + }; + + eventSource.onmessage = (event) => { + try { + const data: MCPHostEvent = JSON.parse(event.data); + + switch (data.type) { + case 'connected': + addOutput('info', `Connected to session ${data.sessionId}`); + break; + case 'stdout': + if (data.data) { + addOutput('stdout', data.data); + } + break; + case 'stderr': + if (data.data) { + addOutput('stderr', data.data); + } + break; + case 'status': + if (data.status) { + setSession(prev => prev ? { ...prev, status: data.status as any } : null); + addOutput('info', `Status: ${data.status}`); + } + break; + case 'error': + if (data.error) { + addOutput('error', `Error: ${data.error}`); + } + break; + case 'exit': + addOutput('info', `Process exited with code ${data.code} (signal: ${data.signal || 'none'})`); + setSession(prev => prev ? { ...prev, status: 'stopped' } : null); + setConnectionStatus('disconnected'); + break; + case 'ping': + // Ignore ping messages + break; + } + } catch (error) { + console.error('Error parsing SSE message:', error); + } + }; + + eventSource.onerror = () => { + setConnectionStatus('disconnected'); + addOutput('error', 'Lost connection to MCP server'); + }; + }; + + const startServer = async () => { + setIsStarting(true); + setOutput([]); + + try { + // For MVP, we'll try to determine the npm package from the GitHub URL + let npmPackage = ''; + let command = ''; + let args: string[] = []; + + // Extract package name from GitHub URL or use a simple approach + if (server.githubUrl) { + const parts = server.githubUrl.split('/'); + const repoName = parts[parts.length - 1]; + // Try common MCP server patterns + if (repoName.includes('mcp-server') || repoName.includes('mcp-')) { + npmPackage = repoName; + } else { + npmPackage = `@${parts[parts.length - 2]}/${repoName}`; + } + } + + // Fallback to a simple echo command for demo + if (!npmPackage) { + command = 'node'; + args = ['-e', 'console.log("MCP Server simulation - This would run an actual MCP server"); process.stdin.resume();']; + } + + const startRequest: MCPHostStartRequest = { + serverId: server.hubId || server.mcpId, + serverName: server.name, + ...(npmPackage ? { npmPackage } : { command, args }) + }; + + addOutput('info', `Starting MCP server: ${server.name}`); + if (npmPackage) { + addOutput('info', `Using npm package: ${npmPackage}`); + } else { + addOutput('info', `Using command: ${command} ${args.join(' ')}`); + } + + const response = await fetch('/v1/host/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(startRequest), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start server'); + } + + const result: MCPHostStartResponse = await response.json(); + + const newSession: MCPHostSession = { + sessionId: result.sessionId, + serverId: startRequest.serverId, + status: result.status as any, + url: result.url, + startTime: new Date().toISOString(), + lastActivity: new Date().toISOString(), + }; + + setSession(newSession); + connectToEventStream(result.sessionId); + + addOutput('info', `Server started successfully!`); + addOutput('info', `Session ID: ${result.sessionId}`); + + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + addOutput('error', `Failed to start server: ${message}`); + } finally { + setIsStarting(false); + } + }; + + const stopServer = async () => { + if (!session) return; + + try { + const response = await fetch(`/v1/host/stop/${session.sessionId}`, { + method: 'DELETE', + }); + + if (response.ok) { + addOutput('info', 'Stop signal sent to server'); + } else { + const error = await response.json(); + addOutput('error', `Failed to stop server: ${error.error}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + addOutput('error', `Error stopping server: ${message}`); + } + }; + + const copyUrl = async () => { + if (!session?.url) return; + + try { + await navigator.clipboard.writeText(session.url); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + } catch (error) { + console.error('Failed to copy URL:', error); + } + }; + + const formatUptime = (startTime: string) => { + const start = new Date(startTime); + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + + if (diffHour > 0) { + return `${diffHour}h ${diffMin % 60}m`; + } else if (diffMin > 0) { + return `${diffMin}m ${diffSec % 60}s`; + } else { + return `${diffSec}s`; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'running': return 'text-green-600 bg-green-100'; + case 'starting': return 'text-yellow-600 bg-yellow-100'; + case 'stopping': return 'text-orange-600 bg-orange-100'; + case 'stopped': return 'text-gray-600 bg-gray-100'; + case 'error': return 'text-red-600 bg-red-100'; + default: return 'text-gray-600 bg-gray-100'; + } + }; + + const getConnectionIcon = () => { + switch (connectionStatus) { + case 'connected': return ; + case 'connecting': return ; + default: return ; + } + }; + + return ( +
+
+

+ + MCP Host +

+ {getConnectionIcon()} +
+ + {!session ? ( +
+

+ Start this MCP server online with one click. You'll get a remote URL that you can use locally. +

+ +
+ ) : ( +
+
+
+ + {session.status} + + {session.startTime && ( + + + {formatUptime(session.startTime)} + + )} +
+ +
+ + {session.url && ( +
+ +
+ + {session.url} + + + {copySuccess && ( + Copied! + )} +
+

+ Use this URL in your MCP client to connect to the hosted server. +

+
+ )} + +
+ + + {output.length > 0 && ( + + {output.length} message{output.length !== 1 ? 's' : ''} + + )} +
+ + {showOutput && ( +
+ {output.length === 0 ? ( +
No output yet...
+ ) : ( + output.map((line, index) => ( +
+ + {line.timestamp.toLocaleTimeString()} + {' '} + {line.text} +
+ )) + )} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/ServerCard.tsx b/client/src/components/ServerCard.tsx index a2c1f60..3c361cd 100644 --- a/client/src/components/ServerCard.tsx +++ b/client/src/components/ServerCard.tsx @@ -52,11 +52,11 @@ export function ServerCard({ server }: ServerCardProps) {
- {server.githubStars.toLocaleString()} + {(server.githubStars || 0).toLocaleString()}
- {server.downloadCount.toLocaleString()} + {(server.downloadCount || 0).toLocaleString()}
diff --git a/client/src/pages/ServerDetails.tsx b/client/src/pages/ServerDetails.tsx index 82f6b7b..40f6b97 100644 --- a/client/src/pages/ServerDetails.tsx +++ b/client/src/pages/ServerDetails.tsx @@ -3,6 +3,7 @@ import { useParams, Link, useLocation } from 'react-router-dom'; import { MCPServer } from '../types'; import { Database, ChevronLeft, ExternalLink, Star, Download, BrainCircuit, FileSearch, Loader } from 'lucide-react'; import { useLanguage } from '../contexts/LanguageContext'; +import { MCPHostComponent } from '../components/MCPHostComponent'; export function ServerDetails() { const { hubId } = useParams<{ hubId: string }>(); @@ -175,11 +176,11 @@ export function ServerDetails() {
- {server.githubStars.toLocaleString()} + {(server.githubStars || 0).toLocaleString()}
- {server.downloadCount.toLocaleString()} + {(server.downloadCount || 0).toLocaleString()}
@@ -226,6 +227,9 @@ export function ServerDetails() {

{server.description}

+ {/* MCP Host Component */} + + {server.Installation_instructions && (

{t('details.installationInstructions')}

diff --git a/client/src/types.ts b/client/src/types.ts index 4a2afb4..a7eaa72 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -26,4 +26,42 @@ export interface MCPServer { githubLatestCommit?: string; githubForks?: number; licenseType?: string | null; +} + +export interface MCPHostSession { + sessionId: string; + serverId: string; + status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error'; + url?: string; + startTime: string; + lastActivity: string; + uptime?: number; + hasOutput?: boolean; +} + +export interface MCPHostStartRequest { + serverId: string; + serverName: string; + gitUrl?: string; + npmPackage?: string; + command?: string; + args?: string[]; +} + +export interface MCPHostStartResponse { + sessionId: string; + status: string; + url: string; + eventStreamUrl: string; +} + +export interface MCPHostEvent { + type: 'connected' | 'stdout' | 'stderr' | 'status' | 'error' | 'exit' | 'ping'; + sessionId?: string; + data?: string; + status?: string; + error?: string; + code?: number | null; + signal?: string | null; + timestamp?: number; } \ No newline at end of file diff --git a/client/vite.config.ts b/client/vite.config.ts index 26b3e03..0c12b77 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -27,6 +27,10 @@ export default defineConfig({ '/v1/hub': { target: BACKEND_API_URL, changeOrigin: true + }, + '/v1/host': { + target: BACKEND_API_URL, + changeOrigin: true } } }, diff --git a/package-lock.json b/package-lock.json index df48f5d..58e5d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1814,6 +1814,12 @@ "node": "*" } }, + "node_modules/child_process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", + "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", + "license": "ISC" + }, "node_modules/chokidar": { "version": "3.6.0", "dev": true, @@ -5869,6 +5875,7 @@ "version": "0.0.0", "dependencies": { "axios": "^1.6.0", + "child_process": "^1.0.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.21.2", diff --git a/server/package.json b/server/package.json index 4da406d..93eb211 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "axios": "^1.6.0", + "child_process": "^1.0.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.21.2", diff --git a/server/src/lib/mcpHost.ts b/server/src/lib/mcpHost.ts new file mode 100644 index 0000000..b62d151 --- /dev/null +++ b/server/src/lib/mcpHost.ts @@ -0,0 +1,246 @@ +import { spawn, ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; + +export interface HostedMCPServer { + sessionId: string; + serverId: string; + process: ChildProcess; + status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error'; + url?: string; + startTime: Date; + lastActivity: Date; + eventEmitter: EventEmitter; + stdout: string; + stderr: string; +} + +export interface MCPHostStartRequest { + serverId: string; + serverName: string; + gitUrl?: string; + npmPackage?: string; + command?: string; + args?: string[]; +} + +export interface MCPHostStartResponse { + sessionId: string; + status: string; + url: string; + eventStreamUrl: string; +} + +class MCPHostManager { + private hostedServers: Map = new Map(); + private readonly MAX_SERVERS = 10; // Limit concurrent servers + private readonly IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes + + constructor() { + // Clean up idle servers periodically + setInterval(() => { + this.cleanupIdleServers(); + }, 5 * 60 * 1000); // Check every 5 minutes + } + + /** + * Start a new MCP server instance + */ + async startServer(request: MCPHostStartRequest): Promise { + // Check server limit + if (this.hostedServers.size >= this.MAX_SERVERS) { + throw new Error('Maximum number of hosted servers reached'); + } + + const sessionId = uuidv4(); + const eventEmitter = new EventEmitter(); + + // For MVP, we'll support npm packages and basic commands + let command: string; + let args: string[] = []; + + if (request.npmPackage) { + // Install and run npm package + command = 'npx'; + args = [request.npmPackage]; + } else if (request.command) { + // Run custom command + command = request.command; + args = request.args || []; + } else { + throw new Error('Either npmPackage or command must be provided'); + } + + try { + const childProcess = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'production' } + }); + + const hostedServer: HostedMCPServer = { + sessionId, + serverId: request.serverId, + process: childProcess, + status: 'starting', + startTime: new Date(), + lastActivity: new Date(), + eventEmitter, + stdout: '', + stderr: '' + }; + + this.hostedServers.set(sessionId, hostedServer); + + // Handle process output + childProcess.stdout?.on('data', (data) => { + const output = data.toString(); + hostedServer.stdout += output; + hostedServer.lastActivity = new Date(); + eventEmitter.emit('stdout', output); + + // Update status when server is ready + if (output.includes('Server listening') || output.includes('MCP server started')) { + hostedServer.status = 'running'; + eventEmitter.emit('status', 'running'); + } + }); + + childProcess.stderr?.on('data', (data) => { + const output = data.toString(); + hostedServer.stderr += output; + hostedServer.lastActivity = new Date(); + eventEmitter.emit('stderr', output); + }); + + childProcess.on('exit', (code, signal) => { + hostedServer.status = code === 0 ? 'stopped' : 'error'; + eventEmitter.emit('exit', { code, signal }); + + // Clean up after a delay + setTimeout(() => { + this.hostedServers.delete(sessionId); + }, 60000); // Keep for 1 minute after exit for status queries + }); + + childProcess.on('error', (error) => { + hostedServer.status = 'error'; + eventEmitter.emit('error', error.message); + console.error(`MCP server ${sessionId} error:`, error); + }); + + // Generate URL for the hosted server (SSE endpoint) + const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; + const url = `${baseUrl}/v1/host/events/${sessionId}`; + hostedServer.url = url; + + // Set status to running after a short delay (optimistic) + setTimeout(() => { + if (hostedServer.status === 'starting') { + hostedServer.status = 'running'; + eventEmitter.emit('status', 'running'); + } + }, 3000); + + return { + sessionId, + status: hostedServer.status, + url, + eventStreamUrl: url + }; + + } catch (error) { + this.hostedServers.delete(sessionId); + throw error; + } + } + + /** + * Stop a hosted MCP server + */ + async stopServer(sessionId: string): Promise { + const server = this.hostedServers.get(sessionId); + if (!server) { + return false; + } + + server.status = 'stopping'; + server.eventEmitter.emit('status', 'stopping'); + + try { + // Try graceful shutdown first + server.process.kill('SIGTERM'); + + // Force kill after timeout + setTimeout(() => { + if (server.process && !server.process.killed) { + server.process.kill('SIGKILL'); + } + }, 5000); + + return true; + } catch (error) { + console.error(`Error stopping server ${sessionId}:`, error); + return false; + } + } + + /** + * Get server status + */ + getServerStatus(sessionId: string): HostedMCPServer | null { + return this.hostedServers.get(sessionId) || null; + } + + /** + * Get all hosted servers + */ + getAllServers(): HostedMCPServer[] { + return Array.from(this.hostedServers.values()); + } + + /** + * Get event emitter for a server + */ + getServerEventEmitter(sessionId: string): EventEmitter | null { + const server = this.hostedServers.get(sessionId); + return server ? server.eventEmitter : null; + } + + /** + * Clean up idle servers + */ + private cleanupIdleServers(): void { + const now = new Date(); + + for (const [sessionId, server] of this.hostedServers) { + const idleTime = now.getTime() - server.lastActivity.getTime(); + + if (idleTime > this.IDLE_TIMEOUT && server.status === 'running') { + console.log(`Cleaning up idle server ${sessionId}`); + this.stopServer(sessionId); + } + } + } + + /** + * Send input to a hosted server's stdin + */ + sendInput(sessionId: string, input: string): boolean { + const server = this.hostedServers.get(sessionId); + if (!server || !server.process.stdin) { + return false; + } + + try { + server.process.stdin.write(input); + server.lastActivity = new Date(); + return true; + } catch (error) { + console.error(`Error sending input to server ${sessionId}:`, error); + return false; + } + } +} + +// Singleton instance +export const mcpHostManager = new MCPHostManager(); \ No newline at end of file diff --git a/server/src/routes/host.ts b/server/src/routes/host.ts new file mode 100644 index 0000000..6429a32 --- /dev/null +++ b/server/src/routes/host.ts @@ -0,0 +1,219 @@ +import { Router, Request, Response } from 'express'; +import { mcpHostManager, MCPHostStartRequest } from '../lib/mcpHost.js'; + +const router = Router(); + +// POST /start - Start a new MCP server instance +router.post('/start', async (req: Request, res: Response): Promise => { + try { + const startRequest: MCPHostStartRequest = req.body; + + // Validate request + if (!startRequest.serverId || !startRequest.serverName) { + res.status(400).json({ error: 'serverId and serverName are required' }); + return; + } + + if (!startRequest.npmPackage && !startRequest.command) { + res.status(400).json({ error: 'Either npmPackage or command must be provided' }); + return; + } + + console.log(`Starting MCP server: ${startRequest.serverName} (${startRequest.serverId})`); + + const result = await mcpHostManager.startServer(startRequest); + + res.status(201).json(result); + console.log(`MCP server started successfully: ${result.sessionId}`); + } catch (error) { + console.error('Error starting MCP server:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to start MCP server'; + res.status(500).json({ error: errorMessage }); + } +}); + +// GET /status/:sessionId - Get status of a hosted server +router.get('/status/:sessionId', (req: Request, res: Response): void => { + try { + const { sessionId } = req.params; + + const server = mcpHostManager.getServerStatus(sessionId); + + if (!server) { + res.status(404).json({ error: 'Server session not found' }); + return; + } + + // Return status without sensitive process information + const status = { + sessionId: server.sessionId, + serverId: server.serverId, + status: server.status, + url: server.url, + startTime: server.startTime, + lastActivity: server.lastActivity, + uptime: Date.now() - server.startTime.getTime(), + hasOutput: server.stdout.length > 0 || server.stderr.length > 0 + }; + + res.json(status); + } catch (error) { + console.error('Error getting server status:', error); + res.status(500).json({ error: 'Failed to get server status' }); + } +}); + +// DELETE /stop/:sessionId - Stop a hosted server +router.delete('/stop/:sessionId', async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + + const success = await mcpHostManager.stopServer(sessionId); + + if (!success) { + res.status(404).json({ error: 'Server session not found or already stopped' }); + return; + } + + res.json({ message: 'Server stop requested', sessionId }); + console.log(`Stop requested for MCP server: ${sessionId}`); + } catch (error) { + console.error('Error stopping MCP server:', error); + res.status(500).json({ error: 'Failed to stop server' }); + } +}); + +// GET /events/:sessionId - SSE endpoint for server events +router.get('/events/:sessionId', (req: Request, res: Response): void => { + try { + const { sessionId } = req.params; + + const eventEmitter = mcpHostManager.getServerEventEmitter(sessionId); + + if (!eventEmitter) { + res.status(404).json({ error: 'Server session not found' }); + return; + } + + // Set up SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Cache-Control'); + + // Send initial connection event + res.write(`data: ${JSON.stringify({ type: 'connected', sessionId })}\n\n`); + + // Set up event listeners + const onStdout = (data: string) => { + res.write(`data: ${JSON.stringify({ type: 'stdout', data })}\n\n`); + }; + + const onStderr = (data: string) => { + res.write(`data: ${JSON.stringify({ type: 'stderr', data })}\n\n`); + }; + + const onStatus = (status: string) => { + res.write(`data: ${JSON.stringify({ type: 'status', status })}\n\n`); + }; + + const onError = (error: string) => { + res.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`); + }; + + const onExit = (exitInfo: { code: number | null; signal: NodeJS.Signals | null }) => { + res.write(`data: ${JSON.stringify({ type: 'exit', ...exitInfo })}\n\n`); + res.end(); + }; + + // Attach event listeners + eventEmitter.on('stdout', onStdout); + eventEmitter.on('stderr', onStderr); + eventEmitter.on('status', onStatus); + eventEmitter.on('error', onError); + eventEmitter.on('exit', onExit); + + // Send current server status + const server = mcpHostManager.getServerStatus(sessionId); + if (server) { + res.write(`data: ${JSON.stringify({ type: 'status', status: server.status })}\n\n`); + + // Send any existing output + if (server.stdout) { + res.write(`data: ${JSON.stringify({ type: 'stdout', data: server.stdout })}\n\n`); + } + if (server.stderr) { + res.write(`data: ${JSON.stringify({ type: 'stderr', data: server.stderr })}\n\n`); + } + } + + // Clean up listeners when client disconnects + req.on('close', () => { + eventEmitter.removeListener('stdout', onStdout); + eventEmitter.removeListener('stderr', onStderr); + eventEmitter.removeListener('status', onStatus); + eventEmitter.removeListener('error', onError); + eventEmitter.removeListener('exit', onExit); + }); + + // Keep connection alive with periodic pings + const pingInterval = setInterval(() => { + res.write(`data: ${JSON.stringify({ type: 'ping', timestamp: Date.now() })}\n\n`); + }, 30000); // Every 30 seconds + + req.on('close', () => { + clearInterval(pingInterval); + }); + + } catch (error) { + console.error('Error setting up SSE:', error); + res.status(500).json({ error: 'Failed to establish event stream' }); + } +}); + +// POST /input/:sessionId - Send input to a hosted server +router.post('/input/:sessionId', (req: Request, res: Response): void => { + try { + const { sessionId } = req.params; + const { input } = req.body; + + if (typeof input !== 'string') { + res.status(400).json({ error: 'Input must be a string' }); + return; + } + + const success = mcpHostManager.sendInput(sessionId, input); + + if (!success) { + res.status(404).json({ error: 'Server session not found or input not available' }); + return; + } + + res.json({ message: 'Input sent successfully' }); + } catch (error) { + console.error('Error sending input to server:', error); + res.status(500).json({ error: 'Failed to send input' }); + } +}); + +// GET /list - List all hosted servers +router.get('/list', (req: Request, res: Response): void => { + try { + const servers = mcpHostManager.getAllServers().map(server => ({ + sessionId: server.sessionId, + serverId: server.serverId, + status: server.status, + startTime: server.startTime, + lastActivity: server.lastActivity, + uptime: Date.now() - server.startTime.getTime() + })); + + res.json({ servers, count: servers.length }); + } catch (error) { + console.error('Error listing servers:', error); + res.status(500).json({ error: 'Failed to list servers' }); + } +}); + +export default router; \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index abad757..bfe3b63 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -5,6 +5,7 @@ import express from 'express'; import cors from 'cors'; import mcpRoutes from './routes/mcp.js'; import hubRoutes from './routes/hub.js'; +import hostRoutes from './routes/host.js'; const app = express(); app.use(cors()); @@ -13,6 +14,7 @@ app.use(express.json()); // Routes app.use('/v1/mcp', mcpRoutes); app.use('/v1/hub', hubRoutes); +app.use('/v1/host', hostRoutes); app.listen(config.server.port, '0.0.0.0', () => { console.log(`Server running on port ${config.server.port}`); diff --git a/server/tests/unit/mcpHost.test.ts b/server/tests/unit/mcpHost.test.ts new file mode 100644 index 0000000..141fd7d --- /dev/null +++ b/server/tests/unit/mcpHost.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mcpHostManager } from '../../src/lib/mcpHost.js'; + +describe('MCP Host Manager', () => { + let initialServerCount = 0; + + beforeEach(() => { + // Track initial state + initialServerCount = mcpHostManager.getAllServers().length; + }); + + afterEach(async () => { + // Clean up all servers created during the test + const servers = mcpHostManager.getAllServers(); + for (const server of servers) { + await mcpHostManager.stopServer(server.sessionId); + } + + // Wait for cleanup + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + it('should create a new server session with npm package', async () => { + const request = { + serverId: 'test-server', + serverName: 'Test Server', + npmPackage: 'echo-mcp-server' + }; + + const result = await mcpHostManager.startServer(request); + + expect(result).toHaveProperty('sessionId'); + expect(result).toHaveProperty('status'); + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('eventStreamUrl'); + expect(result.url).toContain('/v1/host/events/'); + }); + + it('should create a new server session with custom command', async () => { + const request = { + serverId: 'test-server-2', + serverName: 'Test Server 2', + command: 'echo', + args: ['Hello World'] + }; + + const result = await mcpHostManager.startServer(request); + + expect(result).toHaveProperty('sessionId'); + expect(result.status).toBe('starting'); + + // Verify server is tracked (should be initialServerCount + 1) + const servers = mcpHostManager.getAllServers(); + expect(servers.length).toBeGreaterThan(initialServerCount); + + const testServer = servers.find(s => s.serverId === 'test-server-2'); + expect(testServer).toBeDefined(); + }); + + it('should reject request with neither npmPackage nor command', async () => { + const request = { + serverId: 'test-server-3', + serverName: 'Test Server 3' + }; + + await expect(mcpHostManager.startServer(request)).rejects.toThrow( + 'Either npmPackage or command must be provided' + ); + }); + + it('should get server status', async () => { + const request = { + serverId: 'test-server-4', + serverName: 'Test Server 4', + command: 'sleep', + args: ['1'] + }; + + const result = await mcpHostManager.startServer(request); + const status = mcpHostManager.getServerStatus(result.sessionId); + + expect(status).not.toBeNull(); + expect(status?.sessionId).toBe(result.sessionId); + expect(status?.serverId).toBe('test-server-4'); + }); + + it('should return null for non-existent server status', () => { + const status = mcpHostManager.getServerStatus('non-existent-id'); + expect(status).toBeNull(); + }); + + it('should stop a server', async () => { + const request = { + serverId: 'test-server-5', + serverName: 'Test Server 5', + command: 'sleep', + args: ['10'] + }; + + const result = await mcpHostManager.startServer(request); + const stopped = await mcpHostManager.stopServer(result.sessionId); + + expect(stopped).toBe(true); + }); + + it('should return false when stopping non-existent server', async () => { + const stopped = await mcpHostManager.stopServer('non-existent-id'); + expect(stopped).toBe(false); + }); + + it('should get event emitter for server', async () => { + const request = { + serverId: 'test-server-6', + serverName: 'Test Server 6', + command: 'echo', + args: ['test'] + }; + + const result = await mcpHostManager.startServer(request); + const eventEmitter = mcpHostManager.getServerEventEmitter(result.sessionId); + + expect(eventEmitter).not.toBeNull(); + expect(typeof eventEmitter?.emit).toBe('function'); + }); + + it('should return null for non-existent server event emitter', () => { + const eventEmitter = mcpHostManager.getServerEventEmitter('non-existent-id'); + expect(eventEmitter).toBeNull(); + }); +}); \ No newline at end of file