DEV Community

fazal
fazal

Posted on Fully Autonomous

A remote MCP server in ~100 lines, no SDK (on Cloudflare Pages)

I run four small single-purpose sites: a Discord timestamp generator, a zalgo text generator, an image metadata remover and an emoji search. They're static Astro sites on Cloudflare, and the logic runs in the browser.

I wanted AI assistants to be able to use them directly. "Make me a Discord timestamp for 8pm New York on Friday" should just work. So each site now has a remote MCP server at /mcp.

I didn't use the MCP SDK. For a server that only exposes tools, with no auth and no state, the protocol is small enough to write by hand. Here's the whole thing.

What a tools-only server actually has to do

With the Streamable HTTP transport, a client POSTs JSON-RPC messages to one URL. A stateless server can answer every request with plain application/json and skip SSE entirely. You need to handle:

Message Reply
initialize protocol version, capabilities: { tools: {} }, server info
notifications/* (no id) HTTP 202, empty body
ping {}
tools/list your tool definitions (name, description, JSON Schema)
tools/call { content: [{ type: "text", text }] }, with isError: true for bad input
GET 405 (you're allowed to not offer an SSE stream)
OPTIONS CORS preflight

That's it. No sessions: I never send an Mcp-Session-Id, so the client doesn't send one back.

The handler

const VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
const HEADERS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version',
  'X-Robots-Tag': 'noindex',
};
const json = (body, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { ...HEADERS, 'Content-Type': 'application/json' } });
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });

export class ToolError extends Error {} // bad input → isError result the model can read and retry

export function createMcpHandler({ name, version, instructions, tools }) {
  const byName = new Map(tools.map((t) => [t.name, t]));
  const list = tools.map(({ run, ...t }) => t);

  async function dispatch(msg) {
    if (msg?.jsonrpc !== '2.0' || typeof msg.method !== 'string') return fail(msg?.id ?? null, -32600, 'Invalid Request');
    if (msg.id === undefined || msg.id === null) return null; // notification
    const p = msg.params ?? {};
    switch (msg.method) {
      case 'initialize':
        return ok(msg.id, {
          protocolVersion: VERSIONS.includes(p.protocolVersion) ? p.protocolVersion : VERSIONS[0],
          capabilities: { tools: { listChanged: false } },
          serverInfo: { name, version },
          instructions,
        });
      case 'ping': return ok(msg.id, {});
      case 'tools/list': return ok(msg.id, { tools: list });
      case 'tools/call': {
        const tool = byName.get(p.name);
        if (!tool) return fail(msg.id, -32602, `Unknown tool: ${p.name}`);
        try {
          const out = await tool.run(p.arguments ?? {});
          return ok(msg.id, { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out });
        } catch (err) {
          const text = err instanceof ToolError ? err.message : 'Internal error';
          return ok(msg.id, { content: [{ type: 'text', text }], isError: true });
        }
      }
      default: return fail(msg.id, -32601, `Method not found: ${msg.method}`);
    }
  }

  return async (request) => {
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: HEADERS });
    if (request.method !== 'POST') return json({ error: 'POST JSON-RPC to this URL' }, 405);
    let body;
    try { body = JSON.parse(await request.text()); } catch { return json(fail(null, -32700, 'Parse error'), 400); }
    const reply = Array.isArray(body)
      ? (await Promise.all(body.map(dispatch))).filter(Boolean)
      : await dispatch(body);
    const empty = reply === null || (Array.isArray(reply) && reply.length === 0);
    return empty ? new Response(null, { status: 202, headers: HEADERS }) : json(reply);
  };
}
Enter fullscreen mode Exit fullscreen mode

A few choices worth explaining:

  • Bad input is a tool result, not a protocol error. If the model passes timezone: "EST", I return isError: true with "use an IANA zone like America/New_York". The model reads that and retries. A JSON-RPC error would just look like a broken server.
  • structuredContent goes next to the text, so newer clients get typed output and older ones still get JSON in the text.
  • X-Robots-Tag: noindex: this is an API, not a page.

The production version also caps the body at 64 KB and returns a friendlier GET description for humans who open the URL in a browser.

A tool

Each site wraps the same pure engine its UI already uses. On Cloudflare Pages it's one file, functions/mcp.ts:

import { decodeSnowflake, buildTimestampCode } from '../src/lib/discordTimestamp';
import { createMcpHandler, ToolError } from '../src/lib/mcp.js';

const handler = createMcpHandler({
  name: 'discordtimestampgen',
  version: '1.0.0',
  instructions: 'Builds Discord <t:UNIX:FORMAT> timestamps and decodes Discord IDs.',
  tools: [{
    name: 'decode_discord_snowflake',
    description: 'Get the creation date of any Discord user, message, channel or server ID.',
    inputSchema: {
      type: 'object',
      properties: { id: { type: 'string' } },
      required: ['id'],
      additionalProperties: false,
    },
    annotations: { readOnlyHint: true, openWorldHint: false },
    run({ id }) {
      const d = decodeSnowflake(String(id ?? ''));
      if (!d) throw new ToolError(`"${id}" is not a Discord ID (1–20 digits).`);
      return { createdAt: new Date(d.unixMs).toISOString(), timestampCode: buildTimestampCode(d.unixSeconds, 'F') };
    },
  }],
});

export const onRequest: PagesFunction = ({ request }) => handler(request);
Enter fullscreen mode Exit fullscreen mode

For the emoji site, which is a Worker with static assets, I didn't want every page view to start running a script. run_worker_first restricts the Worker to that one path:

"main": "src/worker.js",
"assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": ["/mcp"] }
Enter fullscreen mode Exit fullscreen mode

Everything else is still served straight from static assets.

Two things that bit me

1. My own content negotiation returned 406. One site serves Markdown to agents that send Accept: text/markdown, and returns 406 when an extensionless path's Accept header names neither HTML nor Markdown. I'd exempted /mcp, but then the MCP Registry fetched my /.well-known/mcp-registry-auth key, got a 406, and refused to verify the domain. The fix is to skip negotiation for /.well-known/* too. If you have clever middleware, check what it does to non-browser clients.

2. Test your tools with the queries a model would actually send. My emoji search passed every test I had written, then returned nothing for "flag japan". The Unicode name is flag: Japan, and my word-splitting kept the colon and the capital letter. Lowercase everything and split on anything that isn't a letter or digit. Small, but it's exactly the kind of query an assistant sends.

(One non-bug: I didn't reuse the site's MiniSearch index for emoji search inside the Worker. Rebuilding a full-text index on every cold isolate risks the Worker's per-request CPU limit. Plain token scoring over about 1,900 rows is cheap and good enough.)

Publishing to the official MCP Registry

Domain verification needs no GitHub login. Generate an Ed25519 key, then serve the public half at /.well-known/mcp-registry-auth as v=MCPv1; k=ed25519; p=<base64>:

mcp-publisher login http --domain discordtimestampgen.com --private-key <hex>
mcp-publisher publish   # reads server.json with a "remotes": [{ "type": "streamable-http", "url": ".../mcp" }]
Enter fullscreen mode Exit fullscreen mode

That gets you the com.yourdomain/* namespace.

Try them

All four are free, with no key and no sign-up:

  • https://discordtimestampgen.com/mcp: Discord timestamps and snowflake decoding
  • https://fontglitch.com/mcp: zalgo text and Unicode font styles
  • https://metadataremoverai.com/mcp: read EXIF/GPS/C2PA/AI tags from a public image URL. It's read-only on purpose: stripping stays in the browser, so your files never leave your device
  • https://emojidig.com/mcp: emoji search by meaning and slang

If you build one, the handler above is all you need to start. Questions are welcome in the comments.

Top comments (0)