I got tired of rewriting the same routing layer for every place I deploy to. Cloudflare Workers wants a fetch export. Vercel Edge wants something close but not identical. A plain Bun server wants Bun.serve. Same HTTP semantics, three incompatible entry points, and every migration between providers turned into a small rewrite of glue code that had nothing to do with the actual business logic.
edge.libx.js is the fix I built and now run in production under Bodify, my self-hosted deploy platform. It’s a thin wrapper around itty-router, itself an “ultra-tiny API microrouter” that the itty docs put at 450 bytes to 1KB, “240-500x smaller than Express.js”, and it gives you one router object with CORS, error handling, and sub-router composition baked in, exposing a single fetch-shaped handler. That handler is the only surface that has to change per provider, and it barely changes at all:
import { RouterWrapper } from 'edge.libx.js';
import { json } from 'itty-router';
const app = RouterWrapper.getNew('/v1', { origin: '*' });
app.router.get('/ping', async () => json({ ok: true }));
app.catchNotFound();
export default {
fetch: app.fetchHandler.bind(app),
};
That’s the entire contract. On Cloudflare, that fetch export is the Worker. On Vercel Edge, same file, no changes. Locally, createServerAdapter() wraps it so you get a real dev server without touching the route code. I’ve moved this exact pattern across three providers on different projects and never had to touch a route handler, only the deploy config around it.
The part that’s less obvious, and the reason I’m writing this, is RouterWrapper.asMCP().
Your routes are already agent-callable
MCP (Model Context Protocol) is an open standard for connecting AI applications to external tools. Anthropic describes it as “a USB-C port for AI applications.” Under the hood it’s a JSON-RPC 2.0 protocol, and the piece that matters here is how a client finds and runs tools. From the MCP architecture docs:
MCP clients will use the
*/listmethods to discover available primitives. For example, a client can first list all available tools (tools/list) and then execute them. This design allows listings to be dynamic.
So a server needs two things: a list of callable tools with JSON input schemas (tools/list), and a dispatcher that runs one when asked (tools/call). If you’ve already written a REST API, you’ve already done 90% of that; you just described it as HTTP verbs and paths instead of tool names and schemas. asMCP() closes the gap by reading your router’s own route table.
app.router.get('/users/:id', async (req) => json({ id: req.params.id }));
app.describeMCP('/users/:id', 'GET', {
description: 'Fetch a user by id',
annotations: { readOnlyHint: true },
});
const mcp = app.asMCP({ name: 'my-api', version: '1.0.0' });
asMCP() walks the itty-router internal route table ([method, regex, handlers, path] tuples, no separate registry to keep in sync), turns GET /users/:id into a tool named get_users_by_id, and builds its input schema from three sources: path params become required string fields; query params get inferred by regexing the handler source for req.query.x / destructured { x } = req.query patterns; and describeMCP() lets you override or enrich anything the inference can’t see (descriptions, required fields, types, and MCP annotations like readOnlyHint/destructiveHint). When a tool actually gets called, MCPAdapter doesn’t reimplement your logic. It reconstructs a real HTTP request (http://localhost/users/123, right headers, right body) and runs it through the same fetchHandler your Worker uses, then hands the response content back as the MCP content array. One code path, two protocols.
Bodify’s agent API has around 200 routes. All of them showed up as MCP tools (get_apps, post_apps_by_id_deploy, get_apps_by_id_logs) without a single hand-written tool definition, because I called describeMCP() next to the route it documents instead of maintaining a parallel spec file. That colocation matters more than it sounds: route and tool description drift apart the moment they live in separate files, and nobody notices until an agent calls a tool with the wrong shape.
Where it gets real, not magical
None of this is free in the way “AI-generated tool” pitches usually mean free.
The query-param inference is a regex over handler.toString(). It works because most handlers are small, direct functions, but if you buried req.query.x behind a helper three calls deep, the schema won’t see it, and you fall back to describeMCP() to state it explicitly.
The tool list is cached and only invalidated when the route count changes. That’s the right tradeoff for a router fully wired before serving traffic, but MCP’s own design “allows listings to be dynamic”, and here they aren’t. Register routes after asMCP() has served tools/list once and the new tools won’t surface until the count changes again, which is exactly the case the notifications/tools/list_changed message in the spec exists to handle.
And streaming progress (notifications/progress for long-running calls) degrades silently to a no-op on runtimes without node:async_hooks, which is most edge runtimes. It’s an honest degrade, not a crash, but worth knowing before you build a UX around it.
What I like about the design is that it refuses to be a second system. There’s no MCP-specific route definition, no OpenAPI intermediate, no annotation DSL bolted on. The router you already wrote for HTTP is the same object that answers tools/list and tools/call. I didn’t set out to build an MCP framework; I set out to stop rewriting routers, and the MCP bridge fell out of having one honest source of truth for what an endpoint does.
That’s the part I’m still chewing on: if a REST route table can become a tool manifest this cheaply, does hand-authoring a separate MCP server still earn its keep, or does “expose the API you already have” quietly become the thing most teams reach for first?
References
- itty-router, the wrapped microrouter: itty.dev/itty-router
- Model Context Protocol, architecture,
tools/list/tools/call, notifications: modelcontextprotocol.io/docs/learn/architecture - Model Context Protocol, overview (“USB-C port for AI”): modelcontextprotocol.io
- Repo: github.com/bod-ee/edge.libx.js
- Package: npm: edge.libx.js