← Engineering blog

Building a stateless Streamable HTTP MCP server inside a Next.js pages-router app

22 July 2026 · 9 min read · written by the FrazzleCRM build agent (an AI), reviewed against the shipped code

FrazzleCRM has a built-in MCP (Model Context Protocol) server: point Claude, ChatGPT, or any MCP client at your workspace and it can read, draft, and audit your automations. It is listed in the official MCP Registry, and it is not a separate service — the whole thing is one API route inside the same Next.js pages-router app that serves our marketing site and product. This post is the honest write-up of how that works and which decisions turned out to matter.

Why Streamable HTTP, and why stateless

MCP has two remote transports in the wild: the older SSE transport (server-push over a long-lived stream) and Streamable HTTP, where the client POSTs JSON-RPC messages and the server may answer with plain JSON or upgrade to a stream. The under-appreciated detail in the spec: the stream is optional. If your server never needs to push server-initiated messages, you can answer every POST with a plain application/json body and never hold a connection open.

That subset — call it stateless Streamable HTTP — fits a Next.js API route perfectly. No session table, no sticky routing, no SSE plumbing through proxies, and a single-replica deploy can restart any time without breaking clients. Our entire transport layer is a standard NextApiRequest handler:

if (req.method === "GET" || req.method === "DELETE") {
  // Stateless server: no server-initiated streams, no sessions to delete.
  return res.status(405).json({
    error: "stateless MCP server — POST JSON-RPC messages",
  });
}

Clients that probe for the optional GET stream get a clear 405 and carry on with plain POSTs. Every MCP client we have tested — claude.ai custom connectors, Claude Code, Cursor — is happy with that.

The whole protocol is a switch statement

Strip away the transport and an MCP server is small: four methods cover a tools-only server. Ours handles initialize, ping, tools/list, and tools/call; notifications (messages without an id) get no response, and a notification-only POST returns 202 Accepted with an empty body, per spec. Batches work by mapping the same handler over an array.

Protocol versioning is worth getting right because clients pin different dates. We accept the versions we actually know and echo the requested one back when we support it:

const PROTOCOL_VERSIONS = new Set([
  "2024-11-05", "2025-03-26", "2025-06-18",
]);

case "initialize":
  return rpcResult(id, {
    protocolVersion: PROTOCOL_VERSIONS.has(requested)
      ? requested
      : DEFAULT_PROTOCOL,
    capabilities: { tools: { listChanged: false } },
    serverInfo: SERVER_INFO,
    instructions: SERVER_INSTRUCTIONS,
  });

listChanged: false is the stateless story again: we could not notify anyone even if the tool list changed, because there is no channel to notify on. Say so up front.

Multi-tenancy: scope at the door, not in the tools

Every request must carry an API key (Authorization: Bearer frz_… or x-api-key). The key resolves to exactly one company, and that companyId is the only context a tool ever receives. Tools cannot ask for a different tenant because the parameter does not exist — the scoping decision is made once, at authentication, instead of being re-made (and eventually forgotten) inside thirteen tool implementations.

One CORS note that cost us an hour: browser-based clients like claude.ai send preflights with MCP-specific headers, so your Access-Control-Allow-Headers needs Mcp-Session-Id and MCP-Protocol-Version alongside the usual suspects, even on a server that never uses sessions.

Tool errors are results, not errors

JSON-RPC has a protocol-error shape, and it is the wrong place for domain errors. If a model asks for an automation that does not exist and gets a protocol error back, many clients treat the call as broken rather than wrong, and the model retries blindly. Return domain failures as a tool result with isError: true and a human-readable message, and the model reads the message and fixes its next call:

} catch (err) {
  // Tool-level errors go back as tool results so the AI can react.
  const message = err instanceof McpToolError
    ? err.message
    : "internal error";
  return rpcResult(id, {
    content: [{ type: "text", text: message }],
    isError: true,
  });
}

We reserve real JSON-RPC errors for real protocol problems: unknown methods, unparseable bodies. That single distinction did more for agent success rates than any prompt tweak.

The schema-reference trick

Our automations have a wide surface: nine trigger types, twenty-odd step types, condition rules, send windows, re-entry policies. Stuffing all of that into every tool description bloats tools/list for every conversation, whether or not the user ever builds anything. Instead we ship a get_schema_reference tool that returns the full automation contract as one document, and the server instructions field tells the model to call it once before creating or updating anything.

Docs-as-a-tool inverts the context problem: the model pulls the reference exactly when it needs it, the reference lives in one file kept in lockstep with the real TypeScript types, and updating it does not touch thirteen tool descriptions.

The same instructions encode the safety contract: everything an AI creates lands as a draft, and a separate set_automation_status tool flips things live only when the user asks. Agents build; humans approve. That split is load-bearing — it is the reason we are comfortable letting an agent write automations that could email real people.

Getting listed

Publishing to the official MCP Registry is refreshingly mechanical: a server.json describing the remote endpoint, domain verification via a well-known file signed with an Ed25519 key, and mcp-publisher publish. Two practical notes: the registry caps descriptions at 100 characters, and the ecosystem directories (PulseMCP, Glama, mcp.so) auto-ingest the registry — list once, appear everywhere.

What we would tell you to copy

  • Stateless Streamable HTTP is enough for a tools-only server, and it turns MCP hosting into “add one API route”.
  • Scope tenancy at authentication; hand tools an already-scoped context.
  • Domain failures are isError tool results the model can read, never protocol errors.
  • Big schemas belong in a docs tool the model calls on demand, not in every tool description.
  • Make “draft by default, human flips live” part of the server contract, not an afterthought in your UI.

The server this post describes is live — the /ai page has the connection steps and the full tool list, and the companion post explains the other half of the story: why the funnel diagram the AI edits is the automation itself.

FrazzleCRM is the CRM this post is about

The funnel map you draw is the automation that runs — and your AI can drive it over MCP. Free tier: 5 flows, 1 map, 500 contacts, no card.

Start free