Learn

How to Build a Remote MCP Server: A Worked Example

Andrew McPherson

Depth · Advanced

Good for: Builders

A remote Model Context Protocol (MCP) server is, at its simplest, a single web endpoint that speaks JSON-RPC over HTTP and exposes a few tools an AI agent can call. You do not need a framework, a database, or session handling to ship one. This guide builds the smallest useful version end to end, using the server that powers this site as the worked example. It runs as one Cloudflare Worker, holds no data of its own, and is live at mcp.agenticcommerceatlas.com. If you just want to connect to it rather than build your own, see query the Atlas over MCP.

What you are building

The Atlas MCP server exposes four read-only tools: list_protocols, get_protocol, search_atlas, and get_page. Each one fetches the site’s own machine-readable endpoints and returns the result, so the server is a thin proxy with no separate store. The whole thing is one TypeScript file deployed as a stateless Cloudflare Worker. By the end you will have the same shape: a transport, a JSON-RPC handler, tool definitions, and a deploy step.

The transport, in one paragraph

MCP’s HTTP transport is called Streamable HTTP. The server exposes a single endpoint that supports both POST and GET. The client sends each JSON-RPC message as a POST to that endpoint; the server may reply with a single JSON response or, optionally, stream multiple messages back using Server-Sent Events. Streaming and sessions are optional, so the simplest design is stateless: accept a POST, return one JSON response, keep no session. That is all the Atlas server does, which is why it needs no Durable Objects and costs almost nothing to run.

The JSON-RPC methods you must handle

An MCP client opens a connection by calling initialize, then asks for tools with tools/list, then runs them with tools/call. Handle those three, plus the notifications/initialized notification and ping, and a tools-only server is complete. Here is the Atlas server’s entire dispatch:

async function handleRpc(msg, env) {
  const { id, method, params } = msg;
  switch (method) {
    case 'initialize':
      return rpcResult(id, {
        protocolVersion: params?.protocolVersion || '2025-06-18',
        capabilities: { tools: {} },
        serverInfo: { name: 'agentic-commerce-atlas', version: '1.0.0' },
        instructions: 'Reference for agentic commerce. Use list_protocols and search_atlas to orient, get_protocol and get_page for detail.',
      });
    case 'notifications/initialized':
      return null; // a notification: no response
    case 'ping':
      return rpcResult(id, {});
    case 'tools/list':
      return rpcResult(id, { tools: TOOLS });
    case 'tools/call': {
      const text = await callTool(params?.name, params?.arguments || {}, env);
      return rpcResult(id, { content: [{ type: 'text', text }] });
    }
    default:
      return rpcError(id, -32601, `Method not found: ${method}`);
  }
}

Two small helpers keep the responses valid JSON-RPC:

const rpcResult = (id, result) => ({ jsonrpc: '2.0', id, result });
const rpcError = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });

The important details: initialize echoes the client’s requested protocol version (falling back to the revision you support) and declares capabilities: { tools: {} } so the client knows tools are available. notifications/initialized is a notification, not a request, so you return nothing. Anything you do not recognize gets a standard -32601 “method not found” error.

Defining your tools

A tool is a name, a human-readable description, and a JSON Schema for its arguments. The description matters more than it looks: it is what the model reads to decide when to call the tool, so write it for the agent, not for a docs page. Here are two of the Atlas tools:

const TOOLS = [
  {
    name: 'list_protocols',
    description: 'List the core agentic commerce protocols (ACP, AP2, UCP, MCP, A2A, x402) with maintainer, layer, license, status, and spec URL.',
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  },
  {
    name: 'get_protocol',
    description: 'Get the structured record for one protocol by id (e.g. "acp", "ap2", "ucp").',
    inputSchema: {
      type: 'object',
      properties: { id: { type: 'string', description: 'Protocol id, lowercase.' } },
      required: ['id'],
      additionalProperties: false,
    },
  },
];

tools/list simply returns this array. A tool that takes no arguments still declares an empty object schema, and additionalProperties: false keeps clients from sending fields you do not handle.

Implementing the calls

tools/call hands you a tool name and an arguments object; you do the work and return text. Because the Atlas server is a thin proxy, each tool is a small fetch against the site’s own endpoints:

async function callTool(name, args, env) {
  const base = siteUrl(env); // e.g. https://agenticcommerceatlas.com
  switch (name) {
    case 'list_protocols':
      return await fetchText(`${base}/data/protocols.json`);
    case 'get_protocol': {
      const id = String(args.id || '').toLowerCase();
      const data = JSON.parse(await fetchText(`${base}/data/protocols.json`));
      const match = (data.protocols || []).find((p) => p.id === id);
      if (!match) throw new Error(`Unknown protocol "${id}". Try list_protocols.`);
      return JSON.stringify(match, null, 2);
    }
    case 'get_page': {
      const path = String(args.path || '').replace(/^\/+/, '').replace(/\.md$/, '');
      return await fetchText(`${base}/${path}.md`);
    }
    // search_atlas scores pages from /llms-full.txt and returns the top matches
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

This is the design choice that pays off most. The server stores nothing; it reads the same /data/protocols.json, /llms-full.txt, and per-page .md files the site already publishes for AI ingestion. When the content changes, the tools return the new content with no sync step. If your data is dynamic, point the same pattern at your own API instead.

When a tool fails, return the error inside the result with an isError flag rather than throwing out of the response, so the agent sees what went wrong and can recover:

try {
  const text = await callTool(params.name, params.arguments, env);
  return rpcResult(id, { content: [{ type: 'text', text }] });
} catch (e) {
  return rpcResult(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
}

The HTTP entry point

The Worker’s fetch handler is the transport. It answers CORS preflight, returns a small description on GET, parses the JSON-RPC body on POST (single message or a batched array), and returns 202 when the request contained only notifications:

export default {
  async fetch(request, env) {
    if (request.method === 'OPTIONS') return new Response(null, { headers: cors });
    if (request.method === 'GET') return new Response(serverInfoJson, { headers: jsonCors });
    if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });

    const body = await request.json();
    const messages = Array.isArray(body) ? body : [body];
    const responses = [];
    for (const msg of messages) {
      const res = await handleRpc(msg, env);
      if (res) responses.push(res);
    }
    if (responses.length === 0) return new Response(null, { status: 202, headers: cors }); // notifications only
    const out = Array.isArray(body) ? responses : responses[0];
    return new Response(JSON.stringify(out), { headers: jsonCors });
  },
};

CORS matters for browser-based clients, so allow the Content-Type, Mcp-Session-Id, and Mcp-Protocol-Version headers even though a stateless server ignores the session id.

Configure and deploy

The Atlas server is a separate Worker with a tiny wrangler.jsonc:

{
  "name": "agentic-commerce-atlas-mcp",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-18",
  "compatibility_flags": ["nodejs_compat"],
  "vars": { "SITE_URL": "https://agenticcommerceatlas.com" }
}

Deploy from the project folder:

npm install
npx wrangler deploy

That publishes to https://<name>.<your-subdomain>.workers.dev. To put it on a custom subdomain such as mcp.agenticcommerceatlas.com, add the route in the Worker’s Domains tab in the Cloudflare dashboard. A configuration variable like SITE_URL lets you point the same code at a staging site without changing the source.

Test it

List the tools straight from the terminal:

curl -s https://mcp.agenticcommerceatlas.com \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Call one:

curl -s https://mcp.agenticcommerceatlas.com \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_protocol","arguments":{"id":"ap2"}}}'

For an interactive check, point the MCP Inspector at the endpoint using the Streamable HTTP transport. To use it for real, add it to any MCP-capable client (Claude, Cursor, and others) as a remote HTTP server, as described on the connect page.

What this example leaves out, and when to add it

The server is deliberately minimal, and the gaps are the interesting part. There is no authentication, which is fine for public, read-only content but wrong the moment a tool touches private or per-user data; that is when you add the spec’s OAuth flow. There are no sessions and no SSE streaming, which is fine for fast lookups but limiting for long-running tools that should report progress. And the tools return text rather than structured outputs, which is fine for a reference but worth upgrading when a client needs typed results. The 2025-06-18 MCP revision supports all of these; the lesson is to add them when a tool’s job demands it, not by default.

That is the whole shape of a remote MCP server: a transport, a handful of JSON-RPC methods, tool definitions written for an agent to read, and a deploy. Start with the read-only, stateless version, get it connected to a client, and grow it from there. For where MCP sits among the other agentic commerce standards, see the protocol stack.

FAQ

What is the minimum a remote MCP server has to implement? Three JSON-RPC methods cover the core: initialize, tools/list, and tools/call. In practice you also handle the notifications/initialized notification (reply with nothing) and ping (reply with an empty result). That is enough for a tools-only server most clients can connect to.

Do I need Server-Sent Events or sessions? No. Streamable HTTP allows SSE and sessions, but both are optional. A stateless server can accept a JSON-RPC POST and return a single JSON response, which is the simplest useful design and is what this server does.

Why build it as a thin proxy over your own content? It removes a class of maintenance. Each tool fetches the site’s existing machine-readable endpoints, so when the site updates, the server’s answers update with it, with no separate database to sync.

What did this example deliberately leave out? Authentication and OAuth, sessions, SSE streaming, and structured tool outputs. Add OAuth when tools act on private data, sessions or streaming when a tool is long-running, and structured outputs when clients need typed results.

Primary sources

  1. Model Context Protocol specification: Transports (2025-06-18) · Model Context Protocol, 2025-06-18
  2. Model Context Protocol specification (2025-06-18) · Model Context Protocol, 2025-06-18
  3. Cloudflare Workers documentation · Cloudflare
  4. MCP Inspector · GitHub