Stateless MCP on Edge Functions: Architecture, Trade-offs, and Implementation
A deep technical guide to building a stateless Model Context Protocol server on Vercel Edge Functions — JSON-RPC 2.0 design, the 2026-07-28 spec change, auth and rate limiting without sessions, and headless testing with curl.
The Model Context Protocol (MCP) was designed around a session model borrowed from stdio-based local tooling: a client spawns a process, performs an initialize handshake, receives a notifications/initialized acknowledgment, and keeps that process — and its in-memory state — alive for the life of the conversation. That model maps cleanly onto a desktop app talking to a subprocess. It maps badly onto a Vercel Edge Function that may run on a different isolate for every single request.
The 2026-07-28 revision of the MCP spec formalized a stateless HTTP transport that removes the handshake as a hard prerequisite and lets a server answer tools/call and tools/list as pure, self-contained request/response pairs. This post covers what changed, why the previous session-oriented design was actively hostile to edge runtimes, and how to implement a compliant stateless MCP server on Vercel Edge Functions — including the parts most write-ups skip: auth without server-side session state, rate limiting at the edge, JSON-RPC error code conventions, and headless testing with curl.
This portfolio runs exactly this architecture in production at /api/mcp — a single Edge Function handling get_resume, get_projects, get_capabilities, check_availability, get_manifest, and the one side-effecting tool, contact.
Why stdio-Style Sessions Don’t Map to Serverless
MCP’s original transport assumption is a long-lived, single-tenant process. Three properties of that model break the moment you move to serverless or edge compute:
- No persistent process. A
stdioserver owns a process for the life of the session. An Edge Function invocation is a single request lifecycle — there is no guarantee the next request from the same client lands on the same isolate, and most platforms explicitly do not guarantee it. - No shared memory across invocations. Anything held in a module-level variable to represent “session state” (a negotiated protocol version, a list of previously listed tools, an auth context established during
initialize) is invisible to the next invocation unless it’s externalized to a database, KV store, or the request itself. - Handshake-as-prerequisite conflicts with cold starts. If
tools/callis only valid after a successfulinitializein the same session, and the platform cannot guarantee session continuity, every cold isolate effectively needs to either replay the handshake or reject requests it should be able to serve.
The practical failure mode before the 2026-07-28 update: teams built MCP servers on Lambda or Vercel Functions that tried to fake session continuity with sticky routing, external session stores keyed by a client-generated ID, or (worse) accepting tools/call without initialize and hoping clients didn’t enforce the sequence strictly. All three are workarounds for a protocol assumption that doesn’t hold on the target runtime — not solutions.
What the Stateless Spec Actually Changed
- Handshake elimination as a hard requirement.
initializeis no longer a stateful prerequisite gating every other method. A server can accept a self-containedtools/callortools/listrequest with no prior session context and respond correctly. server/discoveras a direct capability route. Instead of requiring a session to learncapabilities, server identity, and protocol version, clients can callserver/discoverdirectly and get a complete, cacheable answer in one round trip.- Protocol negotiation moves into headers, not session state. Version and method routing information travels on every request instead of being negotiated once and remembered:
Mcp-Protocol-Version— e.g.2026-07-28Mcp-Method— e.g.tools/callortools/listMcp-Name— the tool name, when the method is an execution call
The result is that every request is independently interpretable. A load balancer, edge cache, or WAF can inspect a single request and know exactly what it’s looking at without correlating it to prior traffic. That property — not just “no handshake” — is the actual architectural win, because it’s what makes the protocol compatible with stateless horizontal scaling.
POST /api/mcp HTTP/1.1
Host: hdrx.com.br
Content-Type: application/json
Mcp-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: check_availability
{
"jsonrpc": "2.0",
"id": "req-001",
"method": "tools/call",
"params": {
"name": "check_availability",
"arguments": {}
}
}
A conformant response for a read-only tool call:
{
"jsonrpc": "2.0",
"id": "req-001",
"result": {
"content": [
{
"type": "text",
"text": "{\"status\":\"open\",\"label\":\"Open to full-time roles\"}"
}
],
"isError": false
}
}
Edge Runtime vs. Node.js Runtime: What Actually Constrains an MCP Server
Choosing Edge over a traditional Node.js serverless function is not free. The stateless MCP model happens to fit the edge runtime’s constraints well, but you need to know exactly what those constraints are before you commit an MCP implementation to it.
| Constraint | Edge Runtime (V8 isolate) | Node.js Serverless Function |
|---|---|---|
| Cold start | Low, typically sub-second — no container boot | Higher — full Node.js process + module graph init |
| Available APIs | Web-standard only (fetch, Request, Response, Web Crypto) — no fs, no native Node modules |
Full Node.js API surface, including fs, native addons, most npm packages |
| Execution time limits | Short, hard ceiling (platform-dependent, typically tens of seconds) | Longer ceilings, configurable per plan |
| Memory | Constrained, shared isolate budget | Higher, dedicated per-invocation memory |
| Geographic distribution | Runs at PoPs close to the requester by default | Runs in a fixed region unless explicitly multi-region |
| TCP/raw socket access | Not available | Available — needed for some DB drivers, raw SMTP, etc. |
| npm compatibility | Partial — packages relying on Node built-ins fail at build or runtime | Full |
| Best fit for MCP | Stateless, read-heavy tools with HTTP-only dependencies (fetch to Resend, a REST API, a static data module) | Tools needing a native DB driver, filesystem access, or long-running computation |
The concrete implication for an MCP server: every tool handler must be reachable through fetch-compatible I/O. For this portfolio’s implementation, that’s trivial — the data source is a set of TypeScript modules bundled with the function (src/data/*.ts), and the one network call (contact, via Resend) is a plain HTTP POST. If a tool needed a direct PostgreSQL connection over TCP, or a Node-only PDF-generation library, it would need to run on the Node.js runtime instead — Edge is not a universal substitute, it’s the right tool for a specific I/O profile.
A second, less obvious constraint: no in-memory caching across invocations that you can rely on. Some Edge platforms do keep an isolate warm briefly and a module-level Map will survive a handful of requests in practice, but this is an implementation detail of the platform’s warm-pool behavior, not a guaranteed contract. Building tool logic that silently depends on it (e.g., an in-memory rate-limit counter) will pass local testing and then fail intermittently in production once traffic spreads across isolates.
Handling Auth and Rate Limiting Without Server-Side Sessions
This is the part the spec change doesn’t solve for you. Removing the handshake removes the place where a traditional server would have established an auth context and a session-scoped rate-limit bucket. A stateless MCP server has to re-derive both on every single request.
How do you authenticate a stateless MCP server without sessions?
Use a per-request bearer credential, validated independently on every call — never a server-side session lookup. Two viable patterns:
- Static or scoped API key in the
Authorizationheader, checked against an environment-stored value (or a small allowlist) on every invocation. No session, no server-side state — the credential itself is the complete auth context. - Signed, short-TTL tokens (e.g., a JWT minted by an upstream service) verified via signature check alone, with no database round trip. This keeps the Edge Function’s auth path pure compute, which is what makes it fast at the edge in the first place.
For a public read-only MCP surface like get_resume or get_projects, the pragmatic choice is often no auth at all — the data is already public on the site — reserving credential checks for the side-effecting tool (contact). Don’t add an auth layer a stateless read-only tool doesn’t need; it’s attack surface and latency with no corresponding benefit.
How do you rate-limit an edge function with no shared session state?
You cannot keep counters in the function’s memory and expect correctness — instances aren’t guaranteed to share state. Three approaches that actually work statelessly:
- External atomic counter store (Upstash Redis, Vercel KV, Cloudflare Durable Objects) keyed by client IP or API key, incremented with an atomic
INCR+EXPIREper request. This is the correct general-purpose solution and adds one network round trip per request. - Platform-level edge rate limiting (Vercel Firewall rules, Cloudflare Rate Limiting rules) applied in front of the function, keyed by IP, path, or header. Zero added latency inside your handler, but coarser control — you can’t easily differentiate
tools/listfrom acontactcall at that layer without extra rule complexity. - Token-bucket via signed client state — encode a rate-limit token in a cookie or header, signed and time-windowed, refreshed by the server on each accepted request. Avoids a backing store entirely but is easy to get wrong (replay, clock skew) and isn’t worth the complexity for most MCP surfaces.
In practice, combining (2) for coarse IP-based protection with (1) scoped specifically to the contact tool (since it’s the only one with a real cost — an outbound email) covers the actual risk surface without over-engineering the read-only tools.
JSON-RPC 2.0 Error Code Conventions for MCP Tools
MCP wraps tool execution in JSON-RPC 2.0, which means error handling has two distinct layers, and conflating them is a common implementation bug:
- Protocol-level errors — malformed JSON-RPC envelope, unknown method, invalid params shape. These use the standard JSON-RPC reserved codes and populate the top-level
errorfield; the response has noresult. - Tool-level errors — the RPC call itself succeeded, but the tool logic failed (e.g.,
contactrejected becausebriefexceeds 4000 characters). These are returned as a successful JSON-RPC response withresult.isError: trueand a human-readable message incontent, per MCP convention — not as a JSON-RPC error.
Standard JSON-RPC 2.0 reserved codes to use for protocol-level failures:
| Code | Meaning | When to use it in an MCP server |
|---|---|---|
-32700 |
Parse error | Request body isn’t valid JSON |
-32600 |
Invalid Request | Missing jsonrpc, id, or method |
-32601 |
Method not found | Mcp-Method or params.name doesn’t match a registered tool |
-32602 |
Invalid params | arguments fails schema validation for the named tool |
-32603 |
Internal error | Unhandled exception in the handler — log server-side, never leak the stack trace in the response |
-32000 to -32099 |
Server error (reserved range) | Implementation-specific errors, e.g. rate-limit exceeded |
A rejected tool call due to bad input (invalid params) looks like this — note it’s a JSON-RPC-level error, because the request itself couldn’t be dispatched:
{
"jsonrpc": "2.0",
"id": "req-002",
"error": {
"code": -32602,
"message": "Invalid params: 'contact' field exceeds maximum length of 160 characters"
}
}
Whereas a tool that ran but produced a business-logic failure (e.g., the email provider rejected the send) returns a successful envelope with an error payload inside result:
{
"jsonrpc": "2.0",
"id": "req-003",
"result": {
"content": [
{ "type": "text", "text": "Message could not be delivered. Please try again later." }
],
"isError": true
}
}
Never leak stack traces, internal file paths, or provider-specific error details (e.g., a raw Resend API error body) into either error shape — surface a sanitized message and log the original server-side only.
Testing a Stateless MCP Server Headlessly with curl
Because every request is self-contained, you don’t need an MCP client or a running session to validate a stateless server — curl is sufficient for full integration testing, including in CI.
1. Discover capabilities without a session:
curl -s -X POST https://hdrx.com.br/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: server/discover" \
-d '{"jsonrpc":"2.0","id":"disc-1","method":"server/discover","params":{}}'
2. List available tools:
curl -s -X POST https://hdrx.com.br/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":"list-1","method":"tools/list","params":{}}'
3. Call a read-only tool directly, no prior initialize:
curl -s -X POST https://hdrx.com.br/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: get_projects" \
-d '{"jsonrpc":"2.0","id":"proj-1","method":"tools/call","params":{"name":"get_projects","arguments":{"featured":true}}}'
4. Verify malformed input is rejected with the correct JSON-RPC code:
curl -s -X POST https://hdrx.com.br/api/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"bad-1","method":"tools/call","params":{"name":"contact","arguments":{"brief":""}}}' \
| jq '.error.code'
# expect -32602
5. Confirm the handshake is genuinely optional by running step 3 as the very first request of a fresh session (no prior initialize call in the sequence) and asserting a 200 with a valid result — this is the regression test that actually proves statelessness, as opposed to just proving the endpoint works.
This also means load testing (k6, autocannon, hey) against an MCP server needs zero session setup or connection pooling logic in the test script — every virtual user just fires independent POSTs, which is a reasonably good proxy for how the real traffic pattern behaves too.
Practical Benefits on Vercel and Cloudflare
Running the stateless architecture on Edge Functions delivers three concrete properties, in order of actual impact:
- Zero persistent memory footprint. No session store to provision, monitor, or leak. The entire server is a pure function of
(request) -> response. - Trivial horizontal scale. Because no request depends on a prior request landing on the same isolate, the platform can route every call to whichever PoP is geographically closest with no coordination overhead.
- IP-based rate-limit protection at the platform layer, composed with the tool-scoped store described above, without needing the MCP server itself to track connection state.
- Low, consistent latency globally — the specific number depends on your data source and the requester’s distance to the nearest PoP; treat any fixed millisecond claim skeptically unless you’ve measured it against your own deployment.
The architectural lesson generalizes past MCP: protocols designed for AI agent tooling should default to stateless, self-describing requests unless there’s a specific reason to hold state (streaming a long-running tool call is the main legitimate exception, and that’s precisely why Streamable HTTP exists as a separate transport concern from the stateless request/response model described here). A tool server that can answer tools/call correctly with zero prior context is a tool server that scales without an ops team behind it.
Technical FAQ
Does the stateless MCP spec remove initialize entirely?
No. initialize still exists and stateful clients (e.g., long-running desktop apps) can still use it to negotiate capabilities once per connection. What changed is that servers are no longer required to treat it as a prerequisite — a stateless server must also accept tools/call and tools/list with no prior initialize in the same request stream.
Can a stateless MCP server support streaming tool responses?
Yes, via the Streamable HTTP transport, which is a separate concern from statelessness. A single request can still produce a streamed response (useful for long-running tool calls or incremental results); what stays stateless is that the server doesn’t need to remember anything about the client between separate HTTP requests.
Why not just use WebSockets for MCP on Vercel?
WebSockets require a persistent connection held open by a single server process, which is fundamentally incompatible with Edge Functions and most serverless compute models — there’s no long-lived process to hold the socket. Stateless HTTP POST per call sidesteps this entirely and is what makes MCP deployable on Edge Functions and Cloudflare Workers in the first place.
How do you version an MCP server without breaking existing clients?
Use the Mcp-Protocol-Version header as the negotiation point. A server can inspect the header and branch its response shape per version, or simply reject unsupported versions with a -32600 and a clear message. Because every request carries its own version, you can run multiple protocol versions from the same deployment simultaneously — there’s no session-pinned version to worry about.
Is tools/list safe to cache at the edge?
Yes, for tool sets that don’t change per-caller (i.e., no per-user tool visibility). Since the response depends only on server-side configuration, not on any client state, it’s a strong candidate for edge caching with a short TTL (minutes, not seconds) plus cache invalidation on deploy. Do not cache tools/call responses that depend on arguments unless you’re deliberately building a cache keyed on the full argument set.
What’s the difference between a JSON-RPC error and an MCP tool error?
A JSON-RPC error means the RPC call itself failed to execute — bad method, bad params, internal exception — and is returned in the top-level error field with no result. An MCP tool error means the call executed successfully but the tool’s own logic determined it couldn’t fulfill the request (e.g., a validation failure inside contact); this is returned as a successful JSON-RPC response with result.isError: true. Conflating the two breaks client error handling, since most MCP clients only treat the former as a retryable transport failure.
Why does a read-only MCP tool not need authentication?
If the underlying data is already public — a resume, a project list, availability status — adding an auth layer only increases latency and attack surface without changing what an attacker could otherwise scrape from the public site directly. Reserve credential checks for tools with a real cost or side effect, like sending an email.
Can the same Edge Function serve both the MCP endpoint and REST endpoints?
Yes, but it’s usually cleaner not to. Sharing the underlying data modules (src/data/*.ts) between /api/mcp and REST endpoints like /api/resume keeps a single source of truth, while keeping the routes themselves separate keeps the JSON-RPC dispatch logic and REST response shaping from tangling into one handler.
Questions or project ideas?
Reach out directly to discuss architecture, optimization, or AI agents.