The problem this solves

Most MCP tools are request-response. A client asks a question, the server hits an upstream, returns an answer, done. Domain lookups, API queries, calculations — none of these need session state, none need server-initiated notifications, nothing needs to live between calls. Treat them that way and the failure modes shrink. State is something to opt into when a tool genuinely demands it.

Cloudflare Workers reward this discipline. A request-response Worker scales to zero, deploys in seconds, and bills only for the milliseconds it ran. The moment you reach for Durable Objects or persistent state, you’ve moved into a different operational and cost class. The bare transport pattern below keeps you on the cheap side until something specific demands otherwise.

This guide is for engineers who want to expose existing tools to MCP clients without rewriting the service or adopting a vendor harness above the protocol. The example throughout is domains.aktagon.com — RDAP lookups for AI agents (domain availability, IPs, ASNs) — but the pattern is generic. You’ll end up with a single Worker that serves both an MCP endpoint and a REST API, sharing one set of business logic.

How it works

A remote MCP server exposes tools to any MCP client — Claude Desktop, Claude Code, Cursor, anything that speaks MCP. The MCP endpoint lives at /mcp, the REST API at /check/<domain>. Two pieces make this work. On the server, WebStandardStreamableHTTPServerTransport from the MCP SDK speaks the MCP Streamable HTTP protocol over the standard Web APIs (Request, Response, ReadableStream) — it runs on any runtime that supports them: Cloudflare Workers, Deno, Bun, Node.js 18+. On the client, mcp-remote is a local Node.js process that bridges stdio to your remote HTTP endpoint, handling OAuth and connection lifecycle. No vendor SDK above the protocol. Plain HTTP, plain Worker, one-line client config.

Dependencies

Two production dependencies: @modelcontextprotocol/sdk (the official MCP server implementation) and zod (input validation for tool parameters). That’s the whole list.

Defining tools

Register tools on an McpServer instance. Each tool gets a name, description, a Zod schema for its parameters, and a handler:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

function createMcpServer(): McpServer {
  const server = new McpServer({
    name: "rdap",
    version: "1.0.0",
  });

  server.tool(
    "check_domain",
    "Look up RDAP data for a domain, returning availability, registrar, nameservers, dates, and status.",
    {
      domain: z.string().describe("The domain to look up (e.g. example.com)"),
    },
    async ({ domain }) => {
      const result = await checkDomain(domain);
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify({
              available: result.available,
              summary: result.summary,
            }),
          },
        ],
      };
    },
  );

  return server;
}

The MCP content format wraps responses in { content: [{ type: "text", text: "..." }] }. Return structured JSON as the text value — the LLM parses it. The Zod schema does double duty: runtime validation, and the JSON Schema that MCP clients use to understand what the tool accepts.

The stateless transport pattern

Each request creates a fresh server and transport, handles the MCP message, and returns:

import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";

async function handleMcpRequest(request: Request): Promise<Response> {
  const server = createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  await server.connect(transport);
  return transport.handleRequest(request);
}

sessionIdGenerator: undefined puts the transport in stateless mode — no session tracking, no state between requests. Each POST to /mcp is self-contained.

That means creating a new McpServer on every request. For tool calls that take 100–500 ms hitting an upstream API, the overhead of instantiating the server (sub-millisecond) is noise.

Coexisting with a REST API

The Worker’s fetch handler routes /mcp to the MCP transport and everything else to the existing HTTP handler:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(request.url);
    if (url.pathname === "/mcp") {
      return handleMcpRequest(request);
    }
    return handleRequest(request, env);
  },
};

Both the MCP tool and the REST endpoint call the same checkDomain() function. The MCP layer is a thin adapter — it translates between MCP’s JSON-RPC protocol and the existing business logic.

Wrangler config

The wrangler.toml is a standard Worker config. The only requirement is the nodejs_compat flag — the MCP SDK uses Node.js APIs internally:

name = "rdap"
main = "src/mcp.ts"
compatibility_date = "2026-04-06"
compatibility_flags = ["nodejs_compat"]

routes = [
  { pattern = "domains.aktagon.com", custom_domain = true }
]

Deploy with wrangler deploy.

Connecting from MCP clients

MCP clients that don’t speak Streamable HTTP natively use mcp-remote to reach remote servers. It runs a local stdio server that proxies to your remote HTTP endpoint, handling OAuth and connection lifecycle.

Install it globally:

npm install -g mcp-remote

Then add it to your MCP client config. For Claude Code, create a .mcp.json in your project root, pointing at the live server:

{
  "mcpServers": {
    "rdap": {
      "command": "mcp-remote",
      "args": ["https://domains.aktagon.com/mcp"]
    }
  }
}

domains.aktagon.com gates /mcp with OAuth 2.1 via @cloudflare/workers-oauth-provider. On first connect, mcp-remote opens a browser to the consent screen — paste an API key (starts with rdap_live_, get one from domains.aktagon.com). mcp-remote exchanges the code for an access token and caches it under ~/.mcp-auth/. Subsequent calls reuse the token until it expires.

After restarting the client, the tools register as native MCP tools. Claude Code can call check_domain, check_ip, and the rest directly. Part 2 covers stateful transports.

To connect to multiple instances of the same server — separate environments or tenants — use the --resource flag to isolate OAuth sessions:

{
  "mcpServers": {
    "rdap_prod": {
      "command": "mcp-remote",
      "args": ["https://domains.aktagon.com/mcp"]
    },
    "rdap_staging": {
      "command": "mcp-remote",
      "args": [
        "https://staging.domains.aktagon.com/mcp",
        "--resource",
        "https://staging.domains.aktagon.com/"
      ]
    }
  }
}

Each unique combination of server URL and resource maintains separate OAuth sessions and token storage.

Testing

The bare transport pattern in this article runs without auth, so a local dev server is the simplest place to curl it. With the example mcp.ts and wrangler.toml from earlier dropped into a fresh Worker project, start the dev server:

wrangler dev

In another shell, hit http://localhost:8787/mcp. The transport requires requests that accept both JSON and SSE:

# List available tools
curl -X POST http://localhost:8787/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'

The response comes back as an SSE event:

event: message
data: {"result":{"tools":[{"name":"check_domain",...},{"name":"check_ip",...}]},"jsonrpc":"2.0","id":1}

Call a tool:

curl -X POST http://localhost:8787/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"check_domain","arguments":{"domain":"example.com"}},"id":2}'

The deployed domains.aktagon.com/mcp is OAuth-gated, so raw curl needs a minted access token. For interactive testing against production, use the MCP Inspector — it handles the OAuth flow automatically:

npx @modelcontextprotocol/inspector@latest

Enter https://domains.aktagon.com/mcp as the server URL and click Connect.

Common pitfalls

Forgetting nodejs_compat. The MCP SDK uses Node.js APIs internally. Without compatibility_flags = ["nodejs_compat"] in wrangler.toml, the worker fails at request time, not deploy time, with module-resolution errors that don’t immediately point at the missing flag.

Missing the Accept: application/json, text/event-stream header. The Streamable HTTP transport returns SSE events, not plain JSON. Clients (or curl) that send only Accept: application/json get a 406 from the transport. Both content types are required.

Pasting local curl examples against production. The bare pattern in this article runs without auth — local curls work directly. The deployed domains.aktagon.com/mcp layers OAuth on top via @cloudflare/workers-oauth-provider and rejects raw Authorization: Bearer rdap_live_* headers with 401 invalid_token. Use mcp-remote or the MCP Inspector against production; reserve raw curl for local dev.

Trade-offs

Stateless gives up session resumability, server-initiated notifications, and persistent state across tool calls. In return: no extra infrastructure, smaller bundle, scales to zero. For request-response tools — domain lookups, API queries, calculations — that trade is the right one. The cases where it isn’t — sessions, state machines, push notifications — are what Part 2 covers.

References