A remote MCP server on Cloudflare Workers that exposes domain lookup tools to any MCP client — Claude Desktop, Claude Code, Cursor, anything that speaks MCP. The tools query RDAP, the modern replacement for WHOIS, for domain availability, IP addresses, and ASN data. The same Worker also serves a REST API for humans and scripts. Same business logic, two interfaces. 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) — 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
Tools are registered 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 it at the live domains.aktagon.com server. Auth uses an API key passed through mcp-remote’s --header flag — keys start with rdap_live_, get one from domains.aktagon.com:
{
"mcpServers": {
"rdap": {
"command": "mcp-remote",
"args": [
"https://domains.aktagon.com/mcp",
"--header",
"Authorization: Bearer rdap_live_<your-api-key-here>"
]
}
}
}
OAuth is also supported via mcp-remote and gets its own article. Part 2 covers stateful transports.
After restarting, the tools show up as native MCP tools. Claude Code can call check_domain, check_ip, and the rest directly.
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, resource, and custom headers maintains separate OAuth sessions and token storage.
Testing
Test the MCP endpoint with curl. The transport requires requests that accept both JSON and SSE:
# List available tools
curl -X POST https://domains.aktagon.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer rdap_live_<your-api-key-here>" \
-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 https://domains.aktagon.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer rdap_live_<your-api-key-here>" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"check_domain","arguments":{"domain":"example.com"}},"id":2}'
For interactive testing, use the MCP Inspector:
npx @modelcontextprotocol/inspector@latest
Enter https://domains.aktagon.com/mcp as the server URL and click Connect.
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
- Build a Remote MCP server — Cloudflare’s official guide to remote MCP servers
- @modelcontextprotocol/sdk — the TypeScript MCP SDK
- remote-mcp-authless — Cloudflare’s example remote MCP server
- mcp-remote — stdio-to-HTTP bridge for MCP clients
- MCP specification — the protocol spec
- MCP transports — stdio and Streamable HTTP transport specification