Skip to main content

Home / blog / mcp-server-for-site-performance

We built an MCP server so AI agents can run speed tests — here's how

Published · 8 min read · RankVitals engineering

A growing share of performance work now starts inside an AI assistant: someone asks Claude why their site is slow, and the model does its best from general knowledge — because it cannot launch a throttled Chrome, capture a waterfall, or check what changed since last Tuesday. The Model Context Protocol fixes the plumbing half of that: it gives assistants a standard way to call tools with real capabilities. So we built a remote MCP server for RankVitals. Any MCP-capable client can start a Lighthouse test or SEO crawl, poll for results, read score history, and pull the generated fix plan — with data scoped to the API key that made the call.

This post is the actual architecture — transport, auth, queue, and the Chrome-shaped elephant — with the real code shapes, plus the tradeoffs we have not solved.

Streamable HTTP, stateless on purpose

We use the Streamable HTTP transport: a single POST /mcp endpoint speaking JSON-RPC. The spec supports stateful sessions — a session id issued on initialize, server-to-client notifications over a GET stream — and we deliberately use none of it. Every request builds a fresh McpServer instance scoped to the calling team, connects it to a transport with sessionIdGenerator: undefined, handles the one request, and tears both down when the response closes. GET and DELETE on /mcp return 405.

What statelessness buys: any instance can serve any request, so horizontal scaling needs no sticky sessions and no shared session store, and a crashed process loses nothing. What it costs: no server-initiated push, so a client cannot subscribe to "tell me when my test finishes". Since a Lighthouse run takes 30–90 seconds anyway, polling a results tool fits how agents already work. The whole handler:

app.post('/mcp', async (req, res) => {
  const auth = await verifyApiKey(db, bearerToken(req.headers.authorization));
  if (!auth) {
    res.status(401).json({
      jsonrpc: '2.0',
      error: { code: -32001, message: 'Invalid or missing API key' },
      id: null,
    });
    return;
  }

  const server = buildServer(db, auth); // tools closed over auth.teamId
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on('close', () => { void transport.close(); void server.close(); });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

Auth: high-entropy keys, plain sha256

Authentication is a Bearer API key: rv_live_ followed by 24 random bytes, base64url-encoded — 192 bits of entropy. We store only the sha256 hash and a 12-character prefix for display in the dashboard; the raw key is shown once at creation and never persisted. Verification hashes the presented key, looks the hash up, and rejects revoked keys. A last_used_at stamp is written fire-and-forget so the update never adds latency to the request.

Deliberately not bcrypt. Slow hashes exist to protect low-entropy human passwords from offline brute force; a 192-bit random key is not brute-forceable in this universe, and sha256 lets us do an indexed equality lookup on the hash column. Since the MCP server re-verifies the key on every single request — statelessness again — per-request bcrypt would burn tens of milliseconds of CPU to defend against an attack that does not apply.

const raw = `rv_live_${randomBytes(24).toString('base64url')}`;
// stored: sha256(raw) + a display prefix — the raw key is never persisted

const { data } = await db
  .from('api_keys')
  .select('id, team_id, revoked_at')
  .eq('key_hash', sha256(rawKey))
  .single();
if (!data || data.revoked_at) return null; // 401

The eight tools

Tool design for agents is mostly about honest descriptions: each tool says what it costs, how long it takes, and what to call next. The run tools return a test id plus a shareable report URL and tell the model to poll. The full set:

  • run_performance_test — start a Lighthouse run (desktop or mobile) for a URL; returns a test id to poll.
  • run_seo_audit — crawl the site and run SEO + AEO checks: titles, canonicals, structured data, robots.txt, llms.txt and more.
  • get_test_results — status, scores, grade, Core Web Vitals, audit findings and SEO issues for a test.
  • list_recent_tests — the latest tests for the account with scores and status.
  • get_site_vitals_history — score history for a URL across completed tests, for regression spotting.
  • get_fix_recommendations — the prioritized AI-generated fix plan for a completed test, if one exists.
  • get_credits — remaining test credits.
  • list_monitored_pages — scheduled monitors with frequency and next run time.

Postgres is the queue (we dropped Redis)

Version one used BullMQ on Redis — the default answer for Node job queues, and it worked fine. Then we looked at the Redis dashboard: our managed free-tier instance meters commands per month, and BullMQ workers poll — delayed-job checks, stalled-job checks, heartbeats. An idle worker, processing zero jobs, had burned through most of the monthly command quota. We were paying (or about to pay) for a queue that spent its life asking "anything yet?" into the void.

The fix was noticing what the queue actually needs to be. Our throughput ceiling is Lighthouse, not the queue — one Chrome per test, one or two tests at a time. And every test already lives in a Postgres row with a status column. So the tests table is now the queue. Claiming the next job is one function:

create or replace function claim_next_test()
returns setof tests
language sql security definer
as $$
  update tests t
  set status = 'running', started_at = now()
  where t.id = (
    select id from tests
    where status = 'queued'
    order by created_at asc
    for update skip locked
    limit 1
  )
  returning t.*;
$$;

FOR UPDATE SKIP LOCKED is the entire trick: concurrent workers each lock a different queued row instead of blocking on the same one, so adding workers later requires no code changes. A partial index on created_at where status = 'queued' keeps the claim fast no matter how large the tests table grows. Crash recovery is a second function that fails anything stuck in running for over 15 minutes — no heartbeats, no stalled-job events, just a timestamp comparison. The worker polls claim_next_test every few seconds, which costs nothing measurable on a database we already run. We lost Redis-grade pub/sub latency and gained one fewer moving part, one fewer bill, and a queue we can inspect with a SELECT.

The ~500MB elephant: Chrome

Every Lighthouse run launches a fresh headless Chrome and kills it afterward, in a finally block, unconditionally — a leaked Chrome holds roughly 500 MB, and two leaks on a small VPS is an outage. Fresh-per-run is also about result integrity: a reused browser accumulates warm caches, DNS entries and JIT state that make later runs quietly faster than reality. The consequence is honest capacity math: at ~500 MB and one CPU-hungry simulated-throttling run at a time, worker concurrency is 1–2 per box. That is the real reason the Postgres queue is serial — the bottleneck was never the queue, it was always Chrome.

Connecting an agent

One command in Claude Code (any MCP client with HTTP transport works):

claude mcp add rankvitals --transport http APP_URL=https://rankvitals.io \
  --header "Authorization: Bearer rv_live_YOUR_KEY"

Then ask for something useful: "run a mobile speed test of example.com and tell me the top three fixes." The agent chains run_performance_test → get_test_results (polling until completed) → get_fix_recommendations, and comes back with the prioritized plan — the same loop a human runs through the dashboard, minus the human. The full tool reference lives in the docs, and every plan including the free tier can issue a key — see pricing for the credit allowances.

Honest tradeoffs

Things we chose, and what they cost:

  • Serial queue: tests process mostly one at a time per worker. Under load, tests queue. SKIP LOCKED means scaling is "add another worker box", but we have not needed to yet — and until then, a busy hour means waiting.
  • Single region: every test runs from one location. If your origin is far from it, TTFB carries that distance. Multi-region workers are the obvious next step; the claim function already supports them.
  • Polling, not push: the stateless transport cannot notify clients when a test completes. Agents poll get_test_results. Fine for 30–90 second jobs; wrong for anything long-running.
  • Lab data: simulated throttling, one run per test. Single runs have variance; treat trends across runs as signal and any single run as an estimate.

The unglamorous summary: a stateless HTTP endpoint, sha256 key lookups, a Postgres table doubling as a queue, and Chrome doing all the actual work. It is boring on purpose — every part is inspectable with curl and SELECT — and boring has been very easy to operate.

FAQ

Why a remote server instead of a local stdio MCP?
The value is on the server side: a controlled test environment with throttled Chrome, stored history for regression tracking, and shareable reports. A local stdio server would make your laptop the test lab — different hardware, different network, no history. Remote HTTP means one key works from any MCP client.
What happens if two workers claim a test at the same time?
They cannot. FOR UPDATE SKIP LOCKED makes each concurrent claimer lock a different queued row — the second worker skips the row the first one locked and takes the next. This is standard Postgres queue mechanics and the main reason we felt safe dropping a purpose-built queue.
Does every MCP tool call spend a credit?
No. Only run_performance_test and run_seo_audit start actual tests and spend a credit. Reads — results, history, credits, monitors, fix plans — are free, so an agent can poll and analyze as much as it needs.

Test your site now

Free Lighthouse speed test with waterfall + filmstrip, and an SEO audit in the same tool.

Enter a public website address, including https://. No account is needed to start a trial test.