All posts
ProductEngineering15 Sep 2026 · 4 min read

An MCP server in a day

JPJean Perez

"we need to plan out the MCP asap." That's the line that un-shelved this on 14 September, after the MCP server sat as an idea with no plan behind it.

By the end of that day it had one: a six-phase build (BMU-144 through BMU-149), five decisions written down before any code. By evening there was a real OAuth 2.1 authorization server, eight tools, and a session proven end to end against the official MCP Inspector. All in one day.

Five decisions, written before phase one

docs/mcp/PLAN.md states them as D-1 through D-5. Every later phase points back to one of them.

  • D-1. Host it inside the existing apps/web Next.js app at /api/mcp, not a standalone service. There's no other backend in the repo, and every tool needs the Supabase and tRPC access the rest of the app already has.
  • D-2. Run a second, purpose-built authorization server rather than reuse Supabase's own OAuth. Supabase authenticates the human; it doesn't speak MCP's OAuth 2.1 plus PKCE plus dynamic client registration profile, and third-party clients need a client_id/redirect_uri model Supabase doesn't offer.
  • D-3. Access and refresh tokens are opaque random strings, stored as SHA-256 hashes, never as JWTs. Nothing here needs offline verification, and a hash is trivially revocable where a JWT's exp is just a wait.
  • D-4. run_encode never runs FFmpeg on the server. Every real execution engine lives in a browser tab or the desktop app; a hosted function has no access to a user's files or GPU, no matter how much later work goes into it.
  • D-5. One metering table, mcp_calls, rather than a counter per tool, so a new tool needs no schema change to be measured.

A routing bug before the first tool ran

The first working endpoint hit a Next.js quirk immediately. next.config.ts rewrites the public /mcp/v1 path to /api/mcp/mcp, and Next's router does invoke the right route handler for it. But mcp-handler picks which transport a request is for by matching new URL(req.url).pathname exactly, and the Request object the route handler receives still reports its original /mcp/v1 path, not the rewritten one. Every authenticated request landed on the right code and then 404'd anyway.

The fix is a Proxy around the request that overrides exactly one property:

// apps/web/app/api/mcp/[transport]/route.ts
function withCanonicalPath(request: Request): Request {
  const url = new URL(request.url);
  if (url.pathname === STREAMABLE_HTTP_PATH) return request;
  url.pathname = STREAMABLE_HTTP_PATH;
  const canonicalUrl = url.toString();
  return new Proxy(request, {
    get(target, prop) {
      if (prop === 'url') return canonicalUrl;
      const value = Reflect.get(target, prop, target);
      return typeof value === 'function' ? value.bind(target) : value;
    },
  });
}

Every other property (method, headers, body) forwards untouched to the real request. Only .url lies, and only to the one reader that checks it.

OAuth 2.1, same day

The authorization server landed a few hours later: RFC 8414 discovery, RFC 9728 protected-resource metadata, RFC 7591 dynamic client registration so a client like Claude Code never needs a hand-issued key, PKCE with S256 only, refresh-token rotation, and RFC 7009 revocation. Three tables back it, storing only the SHA-256 hash of a code or token, per D-3, never the raw value.

run_encode follows D-4 literally: it validates a command, inserts a queued row into mcp_jobs, and returns a job id immediately. Nothing runs on the server. A signed-in Studio tab, or the desktop app, polls for jobs addressed to that account and claims one, then runs it through whichever RunEngine is active in that session. get_job reads progress back from the same row; cancel_encode can only set a cancel_requested_at timestamp for a job already running, since the server has no live reference to the tab's in-memory engine, so the tab's own next progress poll is what actually calls .cancel().

What the Inspector found

The official MCP Inspector connected the same day, and it found two bugs that reading the spec wouldn't have.

The consent screen's Authorize and Deny buttons were both disabled the instant either one was clicked, to stop a double-submit. That also meant the browser dropped the clicked button from the form payload before it reached the server, so every Authorize silently resolved as a Deny. The fix writes the decision to a controlled hidden field via flushSync before either button can disable.

The second bug was a host mismatch: every URL in the discovery documents was built from the configured NEXT_PUBLIC_SITE_URL (localhost:3000), while the Inspector was actually running against 127.0.0.1:3000. On this machine those are different origins, so the session cookie the sign-in flow set never made it back. The fix derives every discovery URL and audience check from the request's actual host instead of the configured one.

With both fixed, the Inspector completed the whole loop in one session: discover metadata, self-register, open the real consent screen, exchange the code, list all eight tools, and run visualize_graph against a live command. A refresh-token rotation right after invalidated the old pair and rejected a replayed authorization code. That's D-3 doing its job.

The tool surface it saw, by scope:

ScopeRequiredTools
graph.visualizeyesvisualize_graph
command.explainyesexplain_command
manifest.inspectnoinspect_manifest
library.readnoread_library
encode.runnorun_encode, get_job, cancel_encode
results.writenosave_result

get_job and cancel_encode share run_encode's scope rather than requesting their own, since polling or cancelling a job you already queued isn't a separate grant of trust. Docs at docs/mcp/README.md; a client can be added with claude mcp add --transport http beemmeup https://www.beemmeup.io/mcp/v1 once it's live.