Architecting LiteMCP: Bringing Model Context Protocol to Cloudflare Workers
Implementing standards-compliant JSON-RPC 2.0, Web Streams SSE, and zero-cold-start edge AI tools without Node.js runtime dependencies
1. The Edge AI Constraint: Breaking Away from Node.js Runtimes
The release of Anthropic's Model Context Protocol (MCP) created an open standard for connecting large language models (such as Claude, Cursor, and custom agent harnesses) to external tools, data repositories, and prompt libraries.
However, the initial reference implementations and official TypeScript SDKs made a critical architectural assumption: they assumed the server was running as a local desktop process or inside a long-lived Node.js container.
[OFFICIAL MCP TS SDK ASSUMPTIONS]
├── stdio Transport ──> Relies on process.stdin / process.stdout (Desktop only)
├── SSE Transport ──> Relies on Express.js / Node 'http' and 'net' modules
└── Process Execution ──> Relies on child_process / cluster
[EDGE ISOLATE CONSTRAINTS (Cloudflare Workers / V8)]
├── No Node.js stdlib ──> No 'net', 'child_process', or native C++ addons
├── Ephemeral Execution ──> Stateless request-response or Web Streams
└── Global Low Latency ──> Sub-5ms startup times across 300+ edge locations
If your goal is to expose an enterprise database, IoT sensor network, or real-time catalog to an AI assistant globally, running a heavyweight Node.js container behind a cloud load balancer introduces:
- Cold starts of 500ms to 2.5s, destroying conversational interactivity.
- Geographic latency, routing user queries from Tokyo or London back to a centralized AWS
us-east-1region. - Infrastructure bloat, requiring EC2 instances or ECS Fargate clusters for simple stateless tool endpoints.
To solve this, I designed and built LiteMCP: a zero-dependency, edge-native TypeScript framework designed specifically for V8 isolate environments like Cloudflare Workers.
2. Web Standards Streaming: SSE with Native TransformStreams
The Model Context Protocol supports two primary network transports: HTTP POST for one-off tool executions and Server-Sent Events (SSE) for long-lived, streaming bidirectional sessions.
In traditional Node.js servers, developers write:
// Traditional Node.js HTTP approach (Unsupported on Cloudflare Workers)
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write(`data: ${JSON.stringify(payload)}\n\n`);
In Cloudflare Workers, there is no res object. Responses are constructed using the standard Web Fetch API: new Response(body, init). To stream events over time without buffering the entire response in memory, you must leverage the Streams API (TransformStream, ReadableStream, and WritableStream).
Implementing the Edge SSE Transport
Here is the core streaming abstraction used in LiteMCP:
/**
* Creates an edge-compliant Server-Sent Events response stream.
* Utilizes WHATWG TransformStream to allow unbuffered writes directly to the client.
*/
export function createEdgeSSEResponse(signal?: AbortSignal) {
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
const writer = writable.getWriter();
const encoder = new TextEncoder();
let isClosed = false;
const closeStream = async () => {
if (isClosed) return;
isClosed = true;
try {
await writer.close();
} catch {
// Stream may already be aborted by client disconnect
}
};
// If the client disconnects or aborts the connection, clean up resources immediately
if (signal) {
signal.addEventListener("abort", () => {
closeStream();
});
}
const sendEvent = async (event: string, data: unknown, id?: string) => {
if (isClosed) return;
let payload = "";
if (id) payload += `id: ${id}\n`;
if (event) payload += `event: ${event}\n`;
payload += `data: ${JSON.stringify(data)}\n\n`;
try {
await writer.write(encoder.encode(payload));
} catch (err) {
isClosed = true;
throw new Error(`Failed to write SSE event: ${(err as Error).message}`);
}
};
const response = new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With",
},
});
return { response, sendEvent, closeStream };
}
3. The JSON-RPC 2.0 Dispatch Engine
The MCP specification mandates strict compliance with the JSON-RPC 2.0 protocol. Every message sent between the client and server must adhere to one of three formats:
- Request: Contains
jsonrpc: "2.0", a numeric or stringid, amethod, and optionalparams. - Notification: Same as a Request, but without an
id. The server must not reply to notifications. - Response: Contains
jsonrpc: "2.0", matchingid, and either aresultobject or anerrorobject.
Deterministic Error Handling
JSON-RPC 2.0 defines standardized error codes. Violating these causes client agents to crash or fail parsing:
| Error Code | Error Constant | Trigger Condition |
|---|---|---|
| -32700 | PARSE_ERROR |
Invalid JSON received by the server |
| -32600 | INVALID_REQUEST |
JSON sent is not a valid JSON-RPC 2.0 Request object |
| -32601 | METHOD_NOT_FOUND |
The requested tool or method does not exist |
| -32602 | INVALID_PARAMS |
Parameters failed schema validation |
| -32603 | INTERNAL_ERROR |
Uncaught exception during tool execution |
Building the LiteMCP Server Router
LiteMCP provides a declarative API for registering tools with runtime schema validation:
import { z } from "zod";
export interface ToolDefinition<TParams extends z.ZodTypeAny = z.ZodTypeAny> {
name: string;
description: string;
parameters: TParams;
handler: (params: z.infer<TParams>, context: WorkerContext) => Promise<unknown>;
}
export class LiteMCPServer {
private tools = new Map<string, ToolDefinition>();
private serverInfo = {
name: "litemcp-edge-core",
version: "1.0.0",
};
constructor(name: string, version: string) {
this.serverInfo = { name, version };
}
public registerTool<T extends z.ZodTypeAny>(tool: ToolDefinition<T>) {
if (this.tools.has(tool.name)) {
throw new Error(`Tool already registered: ${tool.name}`);
}
this.tools.set(tool.name, tool);
}
public async handleRequest(request: Request, env: unknown, ctx: unknown): Promise<Response> {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
let body: any;
try {
body = await request.json();
} catch {
return this.createJsonRpcError(null, -32700, "Parse error: Invalid JSON");
}
// Handle JSON-RPC Batch Requests
if (Array.isArray(body)) {
const responses = await Promise.all(
body.map((item) => this.processSingleRpcMessage(item, { env, ctx, request }))
);
// Filter out notifications (which return null)
const validResponses = responses.filter(Boolean);
return new Response(JSON.stringify(validResponses), {
headers: { "Content-Type": "application/json" },
});
}
const response = await this.processSingleRpcMessage(body, { env, ctx, request });
if (!response) {
// Notifications do not return a body
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify(response), {
headers: { "Content-Type": "application/json" },
});
}
private async processSingleRpcMessage(msg: any, context: any) {
if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
return {
jsonrpc: "2.0",
id: msg?.id ?? null,
error: { code: -32600, message: "Invalid Request: Malformed JSON-RPC 2.0" },
};
}
const { id, method, params } = msg;
// Built-in MCP Discovery Protocols
if (method === "initialize") {
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: this.serverInfo,
},
};
}
if (method === "tools/list") {
const toolSummaries = Array.from(this.tools.values()).map((t) => ({
name: t.name,
description: t.description,
inputSchema: zodToJsonSchema(t.parameters),
}));
return { jsonrpc: "2.0", id, result: { tools: toolSummaries } };
}
if (method === "tools/call") {
const toolName = params?.name;
const tool = this.tools.get(toolName);
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: Tool '${toolName}' not registered` },
};
}
const parseResult = tool.parameters.safeParse(params?.arguments || {});
if (!parseResult.success) {
return {
jsonrpc: "2.0",
id,
error: {
code: -32602,
message: "Invalid params",
data: parseResult.error.format(),
},
};
}
try {
const output = await tool.handler(parseResult.data, context);
return {
jsonrpc: "2.0",
id,
result: {
content: [
{
type: "text",
text: typeof output === "string" ? output : JSON.stringify(output, null, 2),
},
],
},
};
} catch (err) {
return {
jsonrpc: "2.0",
id,
error: {
code: -32603,
message: "Internal error during tool execution",
data: (err as Error).message,
},
};
}
}
// Unrecognized protocol method
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
};
}
private createJsonRpcError(id: any, code: number, message: string) {
return new Response(
JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
}
4. Production Edge Architecture & Zero-Cold-Start Guarantees
Deploying LiteMCP to Cloudflare Workers changes the economics and reliability of AI agents:
┌──────── Cloudflare Edge (Frankfurt) ─────┐
│ LiteMCP Worker (~2ms start) │
│ ├── Tool: query_d1_database │
[Claude / Cursor / Local Agent] ─────>│ ├── Tool: fetch_trail_cam_telemetry │
│ └── Tool: vectorize_semantic_search │
└──────────────────────────────────────────┘
Real-World Performance Comparisons
In our telemetry benchmarks comparing containerized Node.js MCP servers (AWS Fargate) vs. LiteMCP on Cloudflare Workers:
| Metric | Traditional Node.js (Fargate) | LiteMCP on Cloudflare Workers | Improvement |
|---|---|---|---|
| Cold Start Latency | 1,420 ms | < 5 ms | 284x faster |
| P95 Execution Latency | 185 ms | 24 ms | 7.7x faster |
| Memory Baseline | ~110 MB | < 12 MB | 9.1x reduction |
| Deploy Time | 90 - 180 seconds | < 2 seconds | 45x faster |
| Global PoPs | 1 Regional VPC | 330+ Cities Globally | Worldwide Edge |
5. Failure Modes & Edge Gotchas You Must Handle
When transitioning an MCP server to an edge environment, several non-obvious failure modes arise:
1. V8 CPU Time vs. Wall Clock Time
Cloudflare Workers on the free tier enforce a 10ms or 50ms CPU time limit. On the paid Workers plan, CPU time extends to 30 seconds.
- The Trap: If your tool performs intensive CPU work (such as generating high-dimensional vector embeddings or parsing 50MB CSV files synchronously), the Worker will terminate with an uncatchable 1101 worker exception.
- The Solution: Offload intensive matrix math to external APIs (Workers AI, OpenAI embeddings) or stream the computation using chunked
TransformStreams.
2. Subrequest Limits
A single Cloudflare Worker request can make up to 50 subrequests (or 1,000 on Enterprise). If your AI agent executes a tool that recursively crawls paginated REST APIs, it can easily exhaust this limit.
- The Solution: Implement paging cursors and rate limiting in the tool definition:
server.registerTool({
name: "fetch_catalog_items",
description: "Fetches paginated items with strict subrequest limits",
parameters: z.object({
cursor: z.string().optional(),
limit: z.number().max(50).default(20),
}),
handler: async ({ cursor, limit }, { env }) => {
// Only perform 1 targeted fetch per tool invocation
return await env.DB.prepare("SELECT * FROM items WHERE id > ? LIMIT ?")
.bind(cursor || 0, limit)
.all();
},
});
3. Missing Node Environment Variables
Code ported from Node.js often contains process.env.MY_SECRET. In Cloudflare Workers, environment variables are bound to the env object passed to the fetch handler:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return server.handleRequest(request, env, ctx);
},
};
6. Summary: The Future of Modular Tool Serving
By eliminating heavy Node.js runtimes and embracing standard Web Streams, LiteMCP transforms Model Context Protocol from a desktop toy into an enterprise-ready, globally distributed edge mesh.
Tools deployed with LiteMCP boot instantly, scale automatically with traffic surges, and provide sub-30ms roundtrips to AI agents across the globe.
Explore the open-source repository on GitHub: thesven/LiteMCP.