prototype-text-display
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:
- Query app capabilities (
get_app_info) - Read current state and available actions (
get_current_state) - Execute actions to change the displayed text (
execute_action)
Architecture
Claude Code <--stdio--> MCP Server <--WebSocket--> Vue Frontend | XState Machine (holds state)Components
| Component | Technology | Purpose |
|---|---|---|
| MCP Server | TypeScript, @modelcontextprotocol/sdk | Exposes tools to Claude |
| State Machine | XState v5 | Manages text state with history |
| Frontend | Vue 3 + Vite | Visual display of state |
| Real-time Sync | WebSocket (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 messageActions
| Action | Payload | Available When | Effect |
|---|---|---|---|
set_text | { text: string } | Always | Set text to value |
append_text | { text: string } | Always | Append to current text |
clear_text | None | State = displaying | Clear all text |
undo | None | history.length > 0 | Restore previous text |
reset | None | Always | Reset 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 connectionUsage
Setup
# Install dependenciescd packages/agentic-ui-prototype/mcp-server && bun installcd 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
- Start frontend:
cd frontend && bun run dev - Restart Claude Code to load MCP server
- 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
| Test | Result |
|---|---|
get_app_info | Returns app description and capabilities |
get_current_state | Returns state, text, and available actions |
set_text | Text displayed, state transitions to displaying |
append_text | Text appended correctly |
clear_text | Text cleared, state returns to idle |
undo | Reverts to previous text from history |
reset | Clears text AND history |
| State-dependent actions | undo only appears when history exists |
| Error handling | Invalid actions return clear error messages |
| Parallel tool calls | Multiple appends execute correctly in sequence |
| Real-time sync | Frontend updates immediately via WebSocket |
Test Scenarios Executed
- Basic text control: Set text to “Hello World” - passed
- Append: Add ” - controlled by Claude!” - passed
- Undo: Reverted to previous text - passed
- Clear: Cleared all text - passed
- Reset: Cleared text and history - passed
- Invalid action: Returned helpful error message - passed
- Missing payload: Validation caught missing required field - passed
- Emoji support: Displayed correctly - passed
- Multi-line text: Built incrementally with appends - passed
Key Learnings
What Worked Well
- State-dependent actions - AI only sees valid actions, reducing errors
- JSON schemas for payloads - Clear contract for action parameters
- WebSocket real-time sync - Immediate visual feedback
- XState for state management - Explicit states and transitions
- Action reasons -
"reason": "Can undo 2 change(s)"helps AI understand context - Error responses - Clear messages guide AI to correct usage
Challenges Encountered
- Two processes required - MCP server + frontend must run separately
- MCP stdio constraint - Can’t use stdout for anything except protocol (use
console.error) - No hot reload for MCP - Need to restart Claude Code to reload server
- 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 handlerasync function shutdown(signal: string) { console.error(`Received ${signal}, shutting down gracefully...`); await closeWebSocketServer(); process.exit(0);}
// Register signal handlersprocess.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));process.on('SIGHUP', () => shutdown('SIGHUP'));
// Check port before startingconst 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 | vMCP Server spawned (stdio) | vWebSocket server binds to port 8765 | v[Normal operation - tools available] | vClaude Code exits (sends SIGTERM) | vGraceful shutdown handler triggered | vWebSocket connections closed | vWebSocket server closed (port released) | vProcess exits cleanlyMulti-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.
Related
- State Machine Architecture - Full architecture specification
- Claude Frontend Control - General patterns for AI UI control
Created: 2025-12-13 Validated: 2025-12-13