A working prototype demonstrating the state machine architecture for AI-controllable UIs.

GitHub Repository: https://github.com/uptownhr/mcp-agentic-ui

Purpose

Validate the architecture defined in State Machine Architecture with a minimal working implementation.

What We Built

A simple text display app where Claude can:

  1. Query app capabilities (get_app_info)
  2. Read current state and available actions (get_current_state)
  3. Execute actions to change the displayed text (execute_action)

Architecture

Claude Code <--stdio--> MCP Server <--WebSocket--> Vue Frontend
|
XState Machine
(holds state)

Components

ComponentTechnologyPurpose
MCP ServerTypeScript, @modelcontextprotocol/sdkExposes tools to Claude
State MachineXState v5Manages text state with history
FrontendVue 3 + ViteVisual display of state
Real-time SyncWebSocket (ws)Push state changes to frontend

State Machine Design

States: idle <-> displaying
Context:
- text: string # Current displayed text
- history: string[] # Previous text values (for undo)
- lastAction: string # Last executed action
- lastError: string # Last error message

Actions

ActionPayloadAvailable WhenEffect
set_text{ text: string }AlwaysSet text to value
append_text{ text: string }AlwaysAppend to current text
clear_textNoneState = displayingClear all text
undoNonehistory.length > 0Restore previous text
resetNoneAlwaysReset to initial state

State-Dependent Actions

The getAvailableActions() function returns only valid actions based on current state:

function getAvailableActions(state: string, context: Context): AvailableAction[] {
const actions = [/* always available: set_text, append_text, reset */];
if (state === 'displaying') {
actions.push({ name: 'clear_text', ... });
if (context.history.length > 0) {
actions.push({
name: 'undo',
reason: `Can undo ${context.history.length} change(s)`
});
}
}
return actions;
}

MCP Tools

get_app_info

Returns static app description:

{
"name": "Agentic UI Prototype",
"purpose": "A simple text display controlled by AI",
"capabilities": ["Display text", "Set text", "Append text", "Clear", "Undo", "Reset"]
}

get_current_state

Returns current state + available actions:

{
"currentState": "displaying",
"text": "Hello World",
"textLength": 11,
"historyCount": 1,
"lastAction": "set_text",
"availableActions": [
{ "name": "set_text", "description": "Set the displayed text", "inputSchema": {...} },
{ "name": "clear_text", "description": "Clear all text", "inputSchema": {...} },
{ "name": "undo", "description": "Undo last change", "reason": "Can undo 1 change(s)" }
]
}

execute_action

Execute an action and return result:

// Request
{ "action": "set_text", "payload": { "text": "Hello World" } }
// Response
{
"success": true,
"action": "set_text",
"newState": "displaying",
"text": "Hello World",
"availableActions": [...]
}

File Structure

packages/agentic-ui-prototype/
├── package.json
├── README.md
├── mcp-server/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts # MCP server + tool handlers
│ ├── machine.ts # XState state machine
│ └── websocket.ts # WebSocket for frontend sync
└── frontend/
├── package.json
├── vite.config.ts
└── src/
├── App.vue
├── components/
│ ├── TextDisplay.vue # Shows the text
│ ├── StateIndicator.vue # Shows state + actions
│ └── ActionLog.vue # Action history
└── composables/
└── useWebSocket.ts # WebSocket connection

Usage

Setup

Terminal window
# Install dependencies
cd packages/agentic-ui-prototype/mcp-server && bun install
cd packages/agentic-ui-prototype/frontend && bun install
# Add to .mcp.json
{
"mcpServers": {
"agentic-ui": {
"command": "bun",
"args": ["run", "/path/to/mcp-server/src/index.ts"]
}
}
}

Running

  1. Start frontend: cd frontend && bun run dev
  2. Restart Claude Code to load MCP server
  3. Open http://localhost:3000

Example Interactions

User: "Set the text to 'Hello World'"
Claude: [calls execute_action { action: "set_text", payload: { text: "Hello World" } }]
-> Frontend updates in real-time
User: "Append ' from Claude!' to it"
Claude: [calls execute_action { action: "append_text", payload: { text: " from Claude!" } }]
-> Text now shows "Hello World from Claude!"
User: "Undo that"
Claude: [calls execute_action { action: "undo" }]
-> Text reverts to "Hello World"

Validation Results

Status: Successfully Validated

The prototype was tested extensively with Claude controlling the UI in real-time.

Test Results

TestResult
get_app_infoReturns app description and capabilities
get_current_stateReturns state, text, and available actions
set_textText displayed, state transitions to displaying
append_textText appended correctly
clear_textText cleared, state returns to idle
undoReverts to previous text from history
resetClears text AND history
State-dependent actionsundo only appears when history exists
Error handlingInvalid actions return clear error messages
Parallel tool callsMultiple appends execute correctly in sequence
Real-time syncFrontend updates immediately via WebSocket

Test Scenarios Executed

  1. Basic text control: Set text to “Hello World” - passed
  2. Append: Add ” - controlled by Claude!” - passed
  3. Undo: Reverted to previous text - passed
  4. Clear: Cleared all text - passed
  5. Reset: Cleared text and history - passed
  6. Invalid action: Returned helpful error message - passed
  7. Missing payload: Validation caught missing required field - passed
  8. Emoji support: Displayed correctly - passed
  9. Multi-line text: Built incrementally with appends - passed

Key Learnings

What Worked Well

  1. State-dependent actions - AI only sees valid actions, reducing errors
  2. JSON schemas for payloads - Clear contract for action parameters
  3. WebSocket real-time sync - Immediate visual feedback
  4. XState for state management - Explicit states and transitions
  5. Action reasons - "reason": "Can undo 2 change(s)" helps AI understand context
  6. Error responses - Clear messages guide AI to correct usage

Challenges Encountered

  1. Two processes required - MCP server + frontend must run separately
  2. MCP stdio constraint - Can’t use stdout for anything except protocol (use console.error)
  3. No hot reload for MCP - Need to restart Claude Code to reload server
  4. Orphaned process issue - See below

Issue: Orphaned MCP Server Processes

Problem

When Claude Code exits, the MCP server process wasn’t being terminated properly. This caused port 8765 to remain in use, preventing new sessions from starting the MCP server.

Symptoms:

  • New Claude Code session shows MCP server “connected” but “Capabilities: none”
  • Port 8765 still in use by old process
  • Error: “Failed to start server. Is port 8765 in use?”

Root Cause

The WebSocket server (ws) wasn’t being closed when the parent process received termination signals. Node/Bun processes don’t automatically clean up server sockets on exit.

Solution: Graceful Shutdown

Added signal handlers and cleanup functions:

websocket.ts:

export function closeWebSocketServer(): Promise<void> {
return new Promise((resolve) => {
if (!wss) { resolve(); return; }
// Close all client connections
for (const client of clients) {
client.close(1000, 'Server shutting down');
}
clients.clear();
// Close the server
wss.close(() => {
wss = null;
resolve();
});
});
}
export function isPortInUse(port: number): Promise<boolean> {
// Check if port is available before binding
}

index.ts:

// Graceful shutdown handler
async function shutdown(signal: string) {
console.error(`Received ${signal}, shutting down gracefully...`);
await closeWebSocketServer();
process.exit(0);
}
// Register signal handlers
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGHUP', () => shutdown('SIGHUP'));
// Check port before starting
const portInUse = await isPortInUse(WS_PORT);
if (portInUse) {
console.error(`Error: Port ${WS_PORT} is already in use.`);
process.exit(1);
}

Result

After implementing graceful shutdown:

  • MCP server properly releases port 8765 on exit
  • New Claude Code sessions start cleanly
  • No more orphaned processes
  • Clear error message if port is somehow still in use

Architecture Insights

MCP Server Lifecycle

Claude Code starts
|
v
MCP Server spawned (stdio)
|
v
WebSocket server binds to port 8765
|
v
[Normal operation - tools available]
|
v
Claude Code exits (sends SIGTERM)
|
v
Graceful shutdown handler triggered
|
v
WebSocket connections closed
|
v
WebSocket server closed (port released)
|
v
Process exits cleanly

Multi-Session Considerations

If running multiple Claude Code sessions with the same MCP server config:

  • Only first session gets the WebSocket port
  • Second session fails to start MCP server (port in use)
  • Options: dynamic port allocation, or single shared server

Follow-up: Single Process Architecture

See Single MCP Server Architecture for an approach that embeds Vite dev server directly in the MCP server for a unified development experience.


Created: 2025-12-13 Validated: 2025-12-13