AI Engineering

Building Benchline: browser tools that are also a stateless MCP server

How Benchline runs 61 browser tools where each one is a pure function shared by a web page, a stateless Streamable HTTP MCP server and a JSON API, using real code from the repo.

Benchline JSON formatter tool page in dark mode, one of 61 browser tools that are also callable over MCP
On this page
  1. The rule: one tool, one pure function
  2. The MCP side of a tool
  3. A generated registry instead of a hand-kept list
  4. The stateless MCP server
  5. Guards in front of the server
  6. Same function, third surface: a JSON API
  7. When one tool needs more than one MCP call
  8. File inputs for an agent with no disk
  9. Tools that touch the network
  10. Making it readable for agents and AI search
  11. Testing every tool through the endpoint
  12. Connecting it to your editor
  13. What I'd keep if I started again

Benchline is a free directory of browser tools for the small, exact jobs around shipping software: formatting JSON, merging PDFs, decoding a JWT, writing an llms.txt file. It lives at tools.fabwaseem.com and the code is MIT-licensed on GitHub.

The part I care about most is that every tool is two things at once. It is a page a person can open, and it is a tool an AI agent can call over the Model Context Protocol. Both run the same function. There is no database, no account and no session.

This post walks through how that works, using the real code from the repository.

The rule: one tool, one pure function#

Every tool lives in its own folder under src/tools/<slug>/:

text
src/tools/<slug>/
  meta.ts     name, tagline, category, FAQ, SEO copy
  tool.tsx    the client workspace a person uses
  lib/        the pure function, covered by vitest
  mcp.ts      the agent entry point, calling the same lib/

The lib/ folder is the tool. The page and the MCP handler are two thin shells around it. That split is what makes the dual surface cheap. When I fix a bug in the JSON formatter, I fix it once, and the page, the MCP server and the HTTP API all pick it up.

Here is the core of the JSON formatter's lib/format.ts:

ts
export function formatJson(input: string, options: FormatOptions): FormatResult {
  const text = input.replace(/^\uFEFF/, "");
  const errors = issuesIn(text);
  if (errors.length > 0) {
    return { valid: false, output: "", errors, tree: null };
  }
  try {
    const value = sortIfNeeded(JSON.parse(text) as unknown, options.sortKeys);
    const output = options.minify ? JSON.stringify(value) : JSON.stringify(value, null, gap(options.indent));
    return { valid: true, output, errors: [], tree: treeOf(value, "root") };
  } catch (error) {
    const issue = issueFromSyntax(text, error);
    return { valid: false, output: "", errors: [issue], tree: null };
  }
}

Nothing in it knows about React, HTTP or MCP. It takes a string and options and returns a result object. That makes it easy to test with vitest and easy to call from anywhere.

The MCP side of a tool#

The agent entry point is mcp.ts. It declares a zod input schema, a zod output schema, and a run function that calls the same formatJson:

ts
export const inputSchema = z.object({
  json: z.string().describe("JSON text to format or validate."),
  indent: z.enum(indents).default("2"),
  sortKeys: z.boolean().default(false).describe("Sort object keys. Array order is kept."),
  minify: z.boolean().default(false),
});

async function run(input: unknown) {
  const parsed = inputSchema.parse(input);
  const result = formatJson(parsed.json, parsed);
  return outputSchema.parse({
    output: result.output,
    valid: result.valid,
    errors: result.errors.map((error) => ({ message: error.message, line: error.line, column: error.column })),
  });
}

export const tool: BenchTool = {
  slug: "json-formatter",
  name: "format_json",
  title: "Format and validate JSON",
  description: "Pretty-print or minify JSON. Returns the formatted text when it is valid, or the line and column of each syntax error.",
  openWorld: false,
  inputSchema,
  outputSchema,
  run,
};

Every tool exports the same shape, a BenchTool:

ts
export type BenchTool = {
  slug: string;
  name: string;
  title: string;
  description: string;
  openWorld: boolean;
  inputSchema: z.ZodType;
  outputSchema: z.ZodType;
  run: (input: unknown) => Promise<unknown>;
};

Two details matter here. The output is parsed against its schema before it leaves run, so an agent never gets a shape the schema did not promise. And openWorld is an explicit flag: it is true only when the handler reaches the network, such as the llms.txt generator reading a sitemap.

A generated registry instead of a hand-kept list#

With 61 tools, a hand-maintained list of imports would drift. Benchline generates it instead. pnpm gen:registry runs as the prebuild step. It reads every meta.ts and mcp.ts, validates them, and writes the registry files.

The validation is strict. It checks the metadata against zod schemas, requires unique slugs, known categories, known Lucide icons and known related tools, and requires a toolOrder that lists every tool exactly once. A tool folder without an mcp.ts fails the script, so a page can't ship without its agent entry point.

The output includes src/registry/mcp-registry.ts, which simply imports every tool:

ts
// Generated by scripts/gen-registry.ts. Do not edit.
import { tool as llmsTxtGenerator } from "@/tools/llms-txt-generator/mcp";
import { tool as aiSeoChecker } from "@/tools/ai-seo-checker/mcp";
import { tools as ogImageGeneratorTools } from "@/tools/og-image-generator/mcp";
import { tool as jwtDecoder } from "@/tools/jwt-decoder/mcp";
// ...one line per tool

It also writes a lightweight mcp-index.ts holding just the name, title, description and openWorld flag, for pages that should list agent tools without loading every handler. Directory pages import metadata only. Each tool's UI is loaded with next/dynamic, so opening the directory doesn't download 61 tool workspaces.

The stateless MCP server#

The MCP endpoint is a single Next.js route handler at src/app/mcp/route.ts:

ts
import { handleMcpRequest } from "@/lib/mcp/handler";

export function POST(request: Request) {
  return handleMcpRequest(request);
}

export function GET() {
  return new Response("Method not allowed", {
    status: 405,
    headers: { allow: "POST", "cache-control": "no-store" },
  });
}

DELETE returns the same 405. Only POST is allowed, because there is no session to stream into or to close.

The handler builds a fresh McpServer and registers every tool from the generated registry:

ts
function createServer() {
  const server = new McpServer({ name: "benchline", version: "1.0.0" });
  for (const tool of mcpTools) {
    server.registerTool(
      tool.name,
      {
        title: tool.title,
        description: tool.description,
        inputSchema: tool.inputSchema,
        outputSchema: tool.outputSchema,
        annotations: {
          readOnlyHint: true,
          idempotentHint: true,
          openWorldHint: tool.openWorld,
        },
      },
      async (args) => {
        try {
          const structured = tool.outputSchema.parse(await tool.run(args));
          return {
            content: [{ type: "text" as const, text: JSON.stringify(structured) }],
            structuredContent: structured as Record<string, unknown>,
          };
        } catch (error) {
          return { isError: true, content: [{ type: "text" as const, text: messageOf(error) }] };
        }
      },
    );
  }
  return server;
}

Every tool is annotated read-only and idempotent, which is true: none of them write anything. A successful call returns structuredContent plus a text copy of the same JSON, for clients that only read text. A thrown error, including a zod validation error, becomes isError: true with a readable message instead of a crashed request.

Then each request gets its own server and transport:

ts
const server = createServer();
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true,
});
await server.connect(transport);
return transport.handleRequest(request);

This is the "stateless" part. sessionIdGenerator: undefined turns off sessions in the MCP TypeScript SDK, so there is no session id to store or route. enableJsonResponse: true answers with a plain JSON body instead of opening an SSE stream. Every request stands alone, so the server scales like any other route handler, and the README's curl examples work without an initialize call first:

bash
curl -sS -X POST https://tools.fabwaseem.com/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

The web-standard transport (WebStandardStreamableHTTPServerTransport) takes a Request and returns a Response, which matches the App Router route handler signature exactly. There's no Node req/res adapter to write.

Guards in front of the server#

Before the server is built, the handler checks two things.

First, the Origin header. A request without one, like curl, is fine. A request with one must match the request host, localhost, or the site URL:

ts
function originAllowed(request: Request) {
  const origin = request.headers.get("origin");
  if (!origin) return true;
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return false;
  }
  const host = request.headers.get("host");
  if (host && url.host === host) return true;
  if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return true;
  return origin === SITE_URL;
}

Second, a rate limit of 60 requests a minute per address. The limiter is a small sliding window kept in memory:

ts
const hits = new Map<string, number[]>();

export function rateLimit(
  key: string,
  limit = 8,
  windowMs = 10 * 60 * 1000,
  now = Date.now(),
): { ok: true } | { ok: false; retryAfter: number } {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < windowMs);
  if (recent.length >= limit) {
    const retryAfter = Math.max(1, Math.ceil((recent[0]! + windowMs - now) / 1000));
    hits.set(key, recent);
    return { ok: false, retryAfter };
  }
  recent.push(now);
  hits.set(key, recent);
  return { ok: true };
}

The MCP route calls it as rateLimit(`mcp:${clientIp(request)}`, 60, 60_000), and a 429 comes back as a JSON-RPC error with a retry-after header. Being in memory, it is per instance, which is a fair trade for a free, keyless service with no database.

Same function, third surface: a JSON API#

Not every client speaks MCP, so the same tool.run is exposed at POST /api/v1/tools/<slug>, with an OpenAPI 3.1 document at /openapi.json built from the zod input schemas. The route looks the tool up by slug and runs it:

ts
const { slug } = await context.params;
const tool = mcpToolBySlug(slug);
if (!tool) {
  return Response.json({ error: "No tool uses that slug." }, { status: 404, headers: { "cache-control": "no-store" } });
}
// ...parse the body, then:
const output = tool.outputSchema.parse(await tool.run(body));
return Response.json(output, { headers: { "cache-control": "no-store" } });

It has its own 60-a-minute bucket. Because both surfaces call one run, they can't disagree about what a tool does.

When one tool needs more than one MCP call#

Most folders export a single tool. The Open Graph image generator is the exception, and the generated registry imports it as tools instead. An agent can't see the visual template picker a person uses on the page, so the generator is split into two calls. list_og_templates returns the template ids, categories, defaults and field schemas. build_open_graph then takes a template id, the fields that template uses, colors, a size and optional images, and returns a PNG plus the Next.js file. The page and the agent still share the same rendering code. The only difference is that the agent asks what it can choose from before choosing.

File inputs for an agent with no disk#

A person drops a file onto the page and the browser does the work. An agent calling a remote server has no local disk on that server, so the image and PDF handlers accept a file in one of two forms, url or data, never both. data is standard or URL-safe base64, or a data: URI, capped at 10 MB once decoded. url has to be public http or https, and it goes through the same SSRF checks described below. The fetch times out after 15 seconds, and a declared or streamed body over 10 MB is refused. The file is read in memory for that one call and not written to disk, which keeps the "nothing is stored" promise true on the server as well as in the browser.

Tools that touch the network#

According to the README, 52 of the 61 tools run entirely in the browser. The other nine have to fetch something public: the llms.txt generator, AI SEO checker, robots.txt checker, meta tags inspector, page to Markdown, DNS lookup, sitemap validator, HTTP headers and SSL checker.

A server that fetches URLs on request is an SSRF risk, so every fetch goes through a guard in src/lib/net/ssrf.ts. parsePublicHttpUrl allows only http and https, refuses credentials in the URL, allows only ports 80 and 443, and blocks names like localhost, *.local and *.internal. Then assertPublicHost resolves the host and checks every address it returns:

ts
for (const record of records) {
  if (isBlockedIp(record.address)) {
    throw new PublicUrlError("That host resolves to a private address.");
  }
}

isBlockedIp refuses loopback, private ranges, link-local, CGNAT (100.64.0.0/10), multicast and the IPv6 equivalents. Redirects are followed by hand, up to four, and each hop is checked again, so a public URL can't redirect into a private one. The browser-facing routes for these nine tools also get a tighter limit of 8 requests per address every 10 minutes.

The registry feeds more than the MCP server. /llms.txt and /llms-full.txt are generated from it, so the site's index for AI crawlers lists every tool page and every agent tool, with its HTTP endpoint, and notes which calls fetch a public URL. Each tool page also carries its own FAQ and how-to steps from meta.ts. Adding a tool updates the directory, the MCP server, the API, the OpenAPI document and llms.txt in one build.

Testing every tool through the endpoint#

The test suite doesn't stop at the pure functions. src/lib/mcp/endpoint.test.ts lists every registered tool through the MCP endpoint, calls every tool through it, fetches a public PDF URL while refusing a private one, and rejects an unsupported protocol version and a foreign origin. There's also scripts/live-mcp-smoke.mts, which connects to the live server with the MCP client, calls tools/list, and then calls every listed tool with a sample input, waiting 1.2 seconds between calls to stay under the rate limit.

Connecting it to your editor#

Since there's no API key, setup is one URL. In Cursor's ~/.cursor/mcp.json:

json
{
  "mcpServers": {
    "benchline": {
      "url": "https://tools.fabwaseem.com/mcp"
    }
  }
}

Claude Desktop uses the same block. VS Code takes "type": "http" with the same URL, and a client that can only launch local commands can wrap it with npx -y mcp-remote https://tools.fabwaseem.com/mcp. The full setup page is at /mcp/guide.

What I'd keep if I started again#

  • Put the logic in a pure function first. The page, the MCP tool and the HTTP endpoint all became small once lib/ existed.
  • Generate the registry. A build-time check that fails when a tool has no mcp.ts keeps the two surfaces in step.
  • Go stateless unless you need sessions. For read-only, idempotent tools, a new server per request with JSON responses is simpler to host and debug than session-bound streams.
  • Treat every fetch as hostile. Resolve the host, check every address, and re-check every redirect.

The stack is Next.js 16 with the App Router, React 19, strict TypeScript, Tailwind CSS v4, shadcn/ui on Base UI, zod 4, vitest and the MCP TypeScript SDK. It runs as a standalone Node 22 build in Docker. If you want to add a tool, the steps are in CONTRIBUTING.md, and you can try the live directory at tools.fabwaseem.com.

Questions