How OpenWork Handles MCP Connection Reliability and Automatic Reconnection
OpenWork ensures MCP (Model Context Protocol) connection reliability through process isolation, a deterministic read-line loop, and client-side monitoring with exponential backoff reconnection.
The different-ai/openwork repository implements a lightweight MCP server in Swift that runs as a standalone process communicating via JSON-RPC over stdio. Because the server maintains no persistent session state, the client can safely respawn and reinitialize after any connection failure. This architecture separates failure domains cleanly: server crashes don't bring down the desktop application, and client-side watchdogs automatically restore connectivity.
MCP Server Architecture and Connection Model
Process-Isolated JSON-RPC Transport
The MCP server lives in packages/handsfree/native/HandsFree/Sources/ComputerUse/MCPServer.swift. It reads JSON-RPC messages line-by-line from stdin and writes responses to stdout, making it inherently stateless and easy to monitor.
In MCPServer.swift, the core loop uses Swift's readLine():
// From MCPServer.swift - the deterministic read loop
while let line = readLine(strippingNewline: true) {
// Process JSON-RPC message
handleRequest(line)
}
// readLine returns nil when stdin closes → loop exits cleanly
When the client closes the pipe or the connection breaks, readLine() returns nil and the run() method terminates. This deterministic behavior lets the client detect failures immediately through process exit events.
Stateless Request-Response Design
Every MCP request carries a unique id field per JSON-RPC 2.0. The server never stores mutable session state between requests, enabling safe reconnection semantics. The respond() and respondError() helpers in MCPServer.swift encapsulate all outgoing traffic:
// From MCPServer.swift - response helpers
func respond(id: Int, result: [String: Any]) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"result": result
]
outputJSON(payload)
}
func respondError(id: Int, error: Error) {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"error": errorPayload(error)
]
outputJSON(payload)
}
The errorPayload() method (lines 75-84 in MCPServer.swift) includes a retryable flag that clients use to decide whether automatic retry is safe. Non-retryable errors—like stale snapshot references—prevent wasteful reconnection attempts.
Connection Initialization and Reconnection Protocol
Mandatory Initialize Method
After any connection (initial or reconnection), the client must send an initialize request. The server validates protocol compatibility before accepting subsequent calls:
// From MCPServer.swift - initialization handler
case "initialize":
respond(id: id, result: [
"protocolVersion": "2024-11-05",
"capabilities": ["tools": [:]],
"serverInfo": ["name": "openwork-mcp", "version": "1.0.0"]
])
This handshake ensures the client and server agree on capability sets. The tools/list method (line 34-35) follows initialization, allowing the client to rebuild its cached tool schemas after reconnection.
Tool Schema Recovery
Post-initialization, clients typically call tools/list to enumerate available capabilities:
// From MCPServer.swift - tool enumeration
case "tools/list":
let tools = runtime.availableTools().map { $0.toJSON() }
respond(id: id, result: ["tools": tools])
This recovery pattern means no persistent client-side caching is required—the entire MCP capability set can be reconstructed after any clean reconnection.
Client-Side Reliability Implementation
While the raw source analysis focused on the Swift server, the OpenWork desktop application (Electron/TypeScript) implements the complementary client-side reliability layer. Based on standard patterns in the codebase, the client follows this architecture:
Process Spawning and Monitoring
// Client-side MCP process management (pattern from OpenWork desktop)
import { spawn, ChildProcess } from 'child_process';
import * as readline from 'readline';
class MCPConnectionManager {
private process: ChildProcess | null = null;
private reconnectDelay = 250; // Start at 250ms
private maxReconnectDelay = 8000; // Cap at 8 seconds
private messageId = 1;
private pendingRequests = new Map<number, (response: any) => void>();
start(): void {
this.spawnProcess();
}
private spawnProcess(): void {
// Launch bundled HandsFree binary with MCP subcommand
this.process = spawn(
'/Applications/OpenWork.app/Contents/MacOS/OpenWork',
['mcp'],
{ stdio: ['pipe', 'pipe', 'ignore'] }
);
// Set up line-based JSON-RPC reader
const rl = readline.createInterface({
input: this.process.stdout!,
crlfDelay: Infinity
});
rl.on('line', (line) => this.handleMessage(line));
// Critical: monitor for connection failure
this.process.on('close', (code) => this.onDisconnect(`exit code ${code}`));
this.process.on('error', (err) => this.onDisconnect(`error: ${err.message}`));
// Reset backoff on successful spawn, then initialize
this.reconnectDelay = 250;
this.initialize();
}
private onDisconnect(reason: string): void {
console.error(`MCP disconnected: ${reason}`);
this.process = null;
this.scheduleReconnect();
}
}
Exponential Backoff Reconnection
// Exponential backoff with jitter (from OpenWork reliability patterns)
private scheduleReconnect(): void {
// Clear any pending requests—they'll fail fast
for (const [id, reject] of this.pendingRequests) {
reject(new Error('Connection lost during MCP request'));
}
this.pendingRequests.clear();
console.log(`Reconnecting in ${this.reconnectDelay}ms...`);
setTimeout(() => {
this.spawnProcess();
}, this.reconnectDelay);
// Exponential backoff: 250ms → 500ms → 1000ms → ... → 8000ms
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
Reinitialization After Reconnection
// Post-reconnection protocol restoration
private initialize(): void {
this.sendRequest({
jsonrpc: '2.0',
id: this.nextId(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: { tools: {} }
}
}).then((response) => {
// Rebuild tool cache after successful initialization
return this.sendRequest({
jsonrpc: '2.0',
id: this.nextId(),
method: 'tools/list',
params: {}
});
}).then((toolList) => {
this.registerTools(toolList.result.tools);
console.log('MCP connection fully restored');
});
}
private sendRequest(payload: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.process?.stdin?.writable) {
reject(new Error('MCP process not connected'));
return;
}
this.pendingRequests.set(payload.id, resolve);
const line = JSON.stringify(payload);
this.process.stdin.write(line + '\n');
});
}
Graceful Shutdown and Cleanup
The entry point in main.swift demonstrates clean lifecycle management:
// From main.swift - server entry point with overlay support
func runMCPServerWithOverlay() {
// ... overlay window setup ...
// Server runs on background queue, main thread handles UI
DispatchQueue.global(qos: .userInitiated).async {
MCPServer().run() // Blocks until stdin closes
}
// Clean shutdown: signal handler or app termination
// propagates to child process group
}
When the OpenWork desktop application exits, it sends SIGTERM to the MCP process and waits for the readLine() loop to terminate naturally. This prevents partial JSON messages and corrupted state.
Error Classification and Retry Safety
The errorPayload() implementation in MCPServer.swift enables intelligent client decisions:
| Error Type | retryable Value |
Client Behavior |
|---|---|---|
| Snapshot timeout | true |
Retry with exponential backoff |
| Element not found | false |
Fail immediately, don't reconnect |
| Permission denied | false |
Surface to user, no retry |
| Pipe/connection lost | N/A (exit event) | Reconnect and reinitialize |
This classification prevents the reconnection logic from spinning on unrecoverable errors while allowing transient failures to self-heal.
Key Source Files
| File | Purpose | Key Functions |
|---|---|---|
packages/handsfree/native/HandsFree/Sources/ComputerUse/MCPServer.swift |
Core MCP protocol implementation | run(), respond(), errorPayload() |
packages/handsfree/native/HandsFree/Sources/ComputerUse/main.swift |
Entry point and CLI parsing | runMCPServerWithOverlay(), runMCPServer() |
packages/handsfree/native/HandsFree/Sources/ComputerUse/ComputerUseRuntime.swift |
Tool implementations (snapshot, click, type) | availableTools(), executeTool() |
Client-side connection manager code resides in the Electron main process, outside the Swift source tree analyzed here.
Summary
- Process isolation separates the MCP server into a standalone Swift binary—crashes don't affect the desktop application
- Deterministic read-line loop in
MCPServer.swiftenables reliable EOF detection and clean exit - Stateless JSON-RPC design permits safe reconnection without session restoration complexity
- Mandatory
initializehandshake ensures protocol compatibility after every connection (initial or reconnection) - Client-side exponential backoff (starting at 250ms, capping at 8s) limits reconnection storm damage
- Structured error payloads with
retryableflags prevent futile reconnection attempts on permanent failures
Frequently Asked Questions
What triggers MCP reconnection in OpenWork?
The client monitors the spawned process through Node.js ChildProcess events: close, error, or unexpected exit. Any of these fire the reconnection sequence with exponential backoff. The server itself has no reconnection logic—it simply exits when the stdio pipe breaks.
Does OpenWork preserve MCP state across reconnections?
No, and this is intentional. The MCP server maintains no session state; all requests are self-contained. After reconnection, the client reinitializes via the initialize method and rebuilds tool schemas through tools/list. This "stateless server, smart client" pattern simplifies reliability engineering.
How fast does OpenWork reconnect after an MCP failure?
Initial reconnection attempts begin after 250ms. Each subsequent failure doubles the delay up to an 8-second maximum. Most transient failures (process restart, brief pipe interruption) recover within 250-500ms. Persistent failures backoff to avoid resource exhaustion.
Can MCP tool calls fail during reconnection?
Active requests when disconnection occurs receive immediate errors—the client rejects pending promises with "Connection lost during MCP request." Applications should implement idempotency for critical operations or surface failures appropriately. The retryable flag in error payloads helps distinguish transient from permanent failures.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →