single-mcp-server
An exploration of embedding the frontend dev server directly into the MCP server for a unified development experience.
Purpose
Eliminate the need to run frontend and MCP server as separate processes. When Claude Code connects to the MCP server, the frontend automatically becomes available with hot reload.
Problem with Current Approach
The prototype requires two separate processes:
bun run devfor Vue frontend (port 3000)- MCP server started by Claude Code (stdio + WebSocket 8765)
This means:
- Developer must remember to start frontend
- Two terminal windows / processes to manage
- Frontend might not be running when testing MCP tools
Proposed Solution: Embed Vite
Vite provides a programmatic API via createServer() that can be embedded into any Node/Bun process.
Architecture
Claude Code <--stdio--> MCP Server | ├── XState Machine (state) ├── WebSocket Server (port 8765, state sync) └── Vite Dev Server (port 3000, frontend + HMR)Single process manages everything.
Implementation
Option A: Vite Programmatic API
import { Server } from '@modelcontextprotocol/sdk/server/index.js';import { createServer as createViteServer } from 'vite';import { startWebSocketServer } from './websocket.js';import path from 'path';import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function main() { // 1. Start Vite dev server for frontend const vite = await createViteServer({ root: path.resolve(__dirname, '../../frontend'), server: { port: 3000, strictPort: true, }, logLevel: 'silent', // Don't pollute MCP stdio }); await vite.listen(); console.error('Frontend available at http://localhost:3000');
// 2. Start WebSocket server for state sync startWebSocketServer(8765);
// 3. Start MCP server on stdio const server = new Server({ name: 'agentic-ui', version: '0.1.0' }, { capabilities: { tools: {} } }); // ... register tools ... const transport = new StdioServerTransport(); await server.connect(transport);}
main().catch(console.error);Option B: Spawn Vite as Child Process
Lighter touch - spawn Vite as a detached child process:
import { spawn } from 'child_process';import path from 'path';
function startFrontend() { const frontendDir = path.resolve(__dirname, '../../frontend');
const vite = spawn('bun', ['run', 'dev'], { cwd: frontendDir, stdio: 'ignore', // Don't interfere with MCP stdio detached: false, // Die when parent dies });
vite.on('error', (err) => { console.error('Failed to start frontend:', err); });
console.error('Frontend starting at http://localhost:3000');}
// Call in main()startFrontend();Comparison
| Aspect | Option A (Embed Vite) | Option B (Spawn) |
|---|---|---|
| Process count | 1 | 2 (parent + child) |
| Dependencies | vite in mcp-server | None new |
| Complexity | Medium | Low |
| Control | Full (programmatic API) | Limited |
| Hot reload | Yes | Yes |
| Cleanup | Automatic | Child dies with parent |
Recommendation: Option B for simplicity. Option A if you need programmatic control over Vite.
Dependencies
For Option A
cd mcp-serverbun add -D vite @vitejs/plugin-vueFor Option B
No additional dependencies - just uses child_process.
Considerations
MCP stdio Constraint
MCP servers communicate via stdio (stdin/stdout). This means:
- All
console.log()output goes to the MCP protocol stream (bad!) - Use
console.error()for logging (goes to stderr) - Vite must be configured with
logLevel: 'silent'or output redirected
Port Conflicts
If ports are already in use:
- Vite: Configure
server.strictPort: falseto find next available - WebSocket: Check if port is free before binding
Graceful Shutdown
Note: Graceful shutdown is now implemented in the prototype. See Prototype: Text Display - Orphaned Process Issue for the full implementation.
process.on('SIGTERM', async () => { await closeWebSocketServer(); // Close state sync WebSocket await vite.close(); // Option A: Close Vite // or viteProcess.kill(); // Option B: Kill Vite child process.exit(0);});Auto-Open Browser
Optionally open browser when MCP server starts:
import { exec } from 'child_process';
function openBrowser(url: string) { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; exec(`${cmd} ${url}`);}
// After Vite startsopenBrowser('http://localhost:3000');Trade-offs
Pros
- Single command - MCP server start = everything starts
- Hot reload works - Full Vite HMR during development
- Consistent state - Frontend always connected to same state machine instance
- No orphan processes - Frontend dies when MCP server dies
Cons
- Heavier MCP server - More responsibilities in one process
- Slower startup - Vite initialization adds ~1-2 seconds
- More dependencies - Option A requires Vite in mcp-server
- Debugging complexity - Issues could be in MCP, Vite, or interaction
Production Considerations
For production, you’d want:
- Build frontend:
cd frontend && bun run build - Serve static files instead of Vite dev server
- Use a simple static file server in MCP (e.g.,
serve-static)
// Production modeif (process.env.NODE_ENV === 'production') { // Serve built static files const app = express(); app.use(express.static(path.resolve(__dirname, '../../frontend/dist'))); app.listen(3000);} else { // Development mode with Vite const vite = await createViteServer({ ... }); await vite.listen();}Next Steps
- Implement Option B (spawn) as the simpler approach
- Test hot reload works correctly
- Add browser auto-open on startup
- Document in prototype README
Pre-requisites Completed
The following foundational work has been completed in the prototype:
- Graceful shutdown - Signal handlers (SIGTERM/SIGINT/SIGHUP) properly close WebSocket server
- Port-in-use check - Startup fails fast with helpful error if port 8765 is taken
- WebSocket cleanup - All client connections closed before server shutdown
These patterns can be reused when implementing the single-process architecture.
Related
- Prototype: Text Display - Current two-process implementation (validated)
- State Machine Architecture - Overall architecture
- GitHub: https://github.com/uptownhr/mcp-agentic-ui - Open source prototype
Created: 2025-12-13 Status: Exploration / Pre-requisites implemented