OPEN TO FULL-TIME ROLES (Available to start immediately. Remote preferred worldwide.)// STACK:WORDPRESS & WOOCOMMERCEREACT & NEXT.JSTYPESCRIPTWEB PERFORMANCEAI INTEGRATIONSAUTOMATION (N8N, MAKE)// PROTOCOL: MCP · JSON-RPC 2.0// STATUS: PRODUCTION READY
OPEN TO FULL-TIME ROLES (Available to start immediately. Remote preferred worldwide.)// STACK:WORDPRESS & WOOCOMMERCEREACT & NEXT.JSTYPESCRIPTWEB PERFORMANCEAI INTEGRATIONSAUTOMATION (N8N, MAKE)// PROTOCOL: MCP · JSON-RPC 2.0// STATUS: PRODUCTION READY
HDRX
• 14 min read
AIMCPArchitectureEdge FunctionsServerlessJSON-RPC

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:

  1. No persistent process. A stdio server 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.
  2. 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.
  3. Handshake-as-prerequisite conflicts with cold starts. If tools/call is only valid after a successful initialize in 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

  1. Handshake elimination as a hard requirement. initialize is no longer a stateful prerequisite gating every other method. A server can accept a self-contained tools/call or tools/list request with no prior session context and respond correctly.
  2. server/discover as a direct capability route. Instead of requiring a session to learn capabilities, server identity, and protocol version, clients can call server/discover directly and get a complete, cacheable answer in one round trip.
  3. 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-28
    • Mcp-Method — e.g. tools/call or tools/list
    • Mcp-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 Authorization header, 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:

  1. External atomic counter store (Upstash Redis, Vercel KV, Cloudflare Durable Objects) keyed by client IP or API key, incremented with an atomic INCR + EXPIRE per request. This is the correct general-purpose solution and adds one network round trip per request.
  2. 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/list from a contact call at that layer without extra rule complexity.
  3. 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 error field; the response has no result.
  • Tool-level errors — the RPC call itself succeeded, but the tool logic failed (e.g., contact rejected because brief exceeds 4000 characters). These are returned as a successful JSON-RPC response with result.isError: true and a human-readable message in content, 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.

Get in Touch →