GNOSEM
Launch

Building an MCP Server on Cloudflare Workers

Architecture notes from building Gnosem: Workers + D1 + Vectorize + Workers AI, with per-user isolation enforced at two layers and sub-100ms typical latency.

By CUETV LLC · · 8-minute read

Gnosem is a Model Context Protocol server that runs entirely on Cloudflare's edge. There is no origin server, no VPC, no container, no long-lived process. Every incoming MCP tool call is handled by a Worker invocation that lives for milliseconds. This post explains what that architecture looks like in practice and where the sharp edges are.

What MCP actually is on the wire

The Model Context Protocol looks intimidating from a distance. It has a spec, an official registry, dozens of transports, and vendor implementations. Underneath, it is JSON-RPC 2.0 over an HTTP request-response cycle. The important pieces for a server:

That is 95% of what a hosted MCP server does. Everything else is transport plumbing.

Streamable HTTP versus SSE

MCP defines two remote transports: SSE (Server-Sent Events, a long-lived streaming connection) and streamable HTTP (plain POST request/response with optional streaming). Gnosem uses streamable HTTP and no streaming — every tool call is a single POST with a JSON body and a JSON response.

Streamable HTTP is the right default for a Worker. Cloudflare Workers charge per invocation, and an invocation that streams for the length of a session bills a lot more than one that runs for 80ms and exits. It also plays well with Cloudflare's cache, edge routing, and DDoS protection because it looks like any other HTTPS POST. SSE requires the client to keep a socket open, which fights against every edge network's ability to route each request independently.

The MCP spec accepts both transports as equal citizens. Clients that want to use Gnosem see "type": "streamable-http" in the server's registry manifest and know to POST tool calls one at a time.

Auth: headers, not sessions

Every request carries an Authorization: Bearer gn_<32 hex> header. The Worker's auth layer does a single query on api_keys keyed by the SHA-256 hash of the plaintext key (the plaintext is never stored). That query joins to users to fetch the plan and subscription state in the same round trip. Total latency: one D1 point lookup, typically 2–5ms at the edge.

There is no session cookie, no OAuth flow, no refresh token. MCP tool calls are naturally stateless — every call from the reading LLM is a fresh POST — so session-level auth would be the wrong abstraction. Bearer-per-call also means the same key works from any client, any machine, any language, without an auth dance.

Per-user isolation at two layers

A memory service is a multi-tenant vector database. If tenant A can see tenant B's embeddings, the product is broken. Gnosem enforces isolation at two layers so a bug in one doesn't create a leak.

Layer 1: D1. Every SQL statement that touches memory data has WHERE user_id = ? bound to the authenticated user's id. There is no query path in the codebase that fetches memory rows without that filter.

Layer 2: Vectorize. Every VECTORIZE.query() call passes filter: { user_id: <user_id> }. Vectorize enforces this filter inside the vector search itself — vectors from other users are excluded from the candidate pool before scoring. This is not just belt-and-suspenders; it means a bug in the D1 layer alone cannot leak vectors across tenants.

For the Vectorize filter to work, you must create a metadata index on user_id when you provision the index:

npx wrangler vectorize create-metadata-index <index_name> \
  --property-name=user_id --type=string

Without this, filter queries silently return empty results. We discovered this in production when a beta tester's search returned nothing — the fix was one command, but the failure mode is a well-hidden footgun.

Keeping D1 and Vectorize in sync

Every memory has two homes: a row in D1 (structured metadata + content) and a vector in Vectorize (768-dim embedding). Both are inserted on write. Both are read on search: Vectorize returns the top-K matching vector IDs, and Gnosem hydrates the D1 rows in a single SELECT ... WHERE id IN (?, ?, ?, ...).

The interesting case is deletion. memory_forget soft-deletes the D1 row (sets forgotten_at) and also calls VECTORIZE.deleteByIds(). The two operations happen in the same request but they are not transactional across services. Cloudflare doesn't offer a 2PC coordinator. The mitigation is that the D1 read always filters forgotten_at IS NULL, so even if the Vectorize delete failed and left a stale vector, the D1 join would drop it from results. The stale vector is a small storage cost, not a correctness bug.

Cold starts (there aren't any)

Workers boot in microseconds. There is no cold-start penalty in the JVM/Python sense because Workers use V8 isolates that come up with the runtime already warm. A memory_search from a client in North America hits an edge server in the same region, does two Worker invocations (one for auth + logic, one for the AI embedding call), one D1 read, one Vectorize read, and returns a response — typically in 50–150ms end to end. From Europe or APAC the latency is similar because the closest colo handles the request without cross-region hops.

What doesn't work yet

Two things a traditional backend gives you for free that a Worker does not:

For a memory service, neither of these is a blocker. Every request is small, every operation is idempotent, and eventual consistency between D1 and Vectorize is acceptable because reads filter on the authoritative source (D1).

The code

The entire Worker is one file — src/worker.js in the gnosem/gnosem repo. If you want to run your own copy: clone, run the four wrangler create commands in the README, set your Stripe keys if you want billing, and deploy. It's MIT licensed.

The hosted service at gnosem.dev is the recommended way to use it — one API key that follows you across every MCP client, no infra to run — but if you'd rather own your own memory graph on your own account, the self-host path is fully supported.