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:

  1. bun run dev for Vue frontend (port 3000)
  2. 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

mcp-server/src/index.ts
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

AspectOption A (Embed Vite)Option B (Spawn)
Process count12 (parent + child)
Dependenciesvite in mcp-serverNone new
ComplexityMediumLow
ControlFull (programmatic API)Limited
Hot reloadYesYes
CleanupAutomaticChild dies with parent

Recommendation: Option B for simplicity. Option A if you need programmatic control over Vite.

Dependencies

For Option A

Terminal window
cd mcp-server
bun add -D vite @vitejs/plugin-vue

For 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: false to 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 starts
openBrowser('http://localhost:3000');

Trade-offs

Pros

  1. Single command - MCP server start = everything starts
  2. Hot reload works - Full Vite HMR during development
  3. Consistent state - Frontend always connected to same state machine instance
  4. No orphan processes - Frontend dies when MCP server dies

Cons

  1. Heavier MCP server - More responsibilities in one process
  2. Slower startup - Vite initialization adds ~1-2 seconds
  3. More dependencies - Option A requires Vite in mcp-server
  4. Debugging complexity - Issues could be in MCP, Vite, or interaction

Production Considerations

For production, you’d want:

  1. Build frontend: cd frontend && bun run build
  2. Serve static files instead of Vite dev server
  3. Use a simple static file server in MCP (e.g., serve-static)
// Production mode
if (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

  1. Implement Option B (spawn) as the simpler approach
  2. Test hot reload works correctly
  3. Add browser auto-open on startup
  4. 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.


Created: 2025-12-13 Status: Exploration / Pre-requisites implemented