// TRANSMISSION LOG /

One Tool Registry, Two Front Doors: Rebuilding reynchr.com

This site went from five static HTML files to an Express app with a Postgres CMS, two-factor auth, and an MCP server behind its own OAuth 2.1 provider. The design decision everything else hangs off: the MCP server and the dashboard's AI console are not two features, they're one list of eleven tools served twice. Plus the login form I forgot I'd written, which quietly bypassed the MFA I'd just shipped.

#reynchr-com #mcp #oauth #postgres #express #claude #build-log

This site used to be five static HTML files and a folder of DMGs. That was fine until I wanted to write a blog post from my phone, and then it wasn't fine at all.

What it is now: an Express app with a Postgres database, a control room at /admin with real user accounts and two-factor auth, popup announcements with date ranges, and an MCP server at /mcp behind its own OAuth 2.1 provider so Claude can write posts for me. The whole thing is about 2,800 lines of JavaScript with no framework and no build step.

The part I want to write about is the piece in the middle. Adding an MCP server and adding an AI console to the dashboard sound like two features. They're one feature written once, and that turned out to be the design decision the rest of the site hangs off.

One registry, two front doorsFront doorsOAuth 2.1 + PKCEsession + TOTPClaude app or ClaudeCodeBrowser at /adminmcp.jsai.jstools.js: 11 toolsPostgres
One registry, two front doors

One list of tools, served two ways

There are eleven things you can do to this site programmatically: list, get, create, update and delete posts; list and update products; list, create, update and delete announcements. They live in one file, src/tools.js, as plain objects:

{ name: "create_post", title: "Create post",
  description: "Create a blog post. Body is Markdown (GitHub flavoured). Defaults to a draft; pass status 'published' to publish immediately.",
  schema: { title: z.string().min(1), body_md: z.string().default(""), /* ... */ },
  run: async (args) => { /* ... */ } }

A name, a description, a zod shape, and a function. Nothing about MCP. Nothing about the Anthropic SDK.

Then two adapters, each small enough to read in one sitting. src/mcp.js is 32 lines and registers the list on an McpServer:

for (const t of siteTools({ siteUrl, userId })) {
  server.registerTool(t.name, { title: t.title, description: t.description, inputSchema: t.schema },
    async (args) => {
      const r = await t.run(args ?? {});
      return { content: [{ type: "text", text: r.text }], ...(r.isError ? { isError: true } : {}) };
    });
}

src/ai.js wraps the same list with betaZodTool and hands it to the SDK's tool runner for the dashboard console. The payoff is the thing I keep recommending to people: I add a tool once and both surfaces get it. No drift between what Claude can do from my phone and what the little text box in my dashboard can do. No second description to keep in sync, which matters more than it sounds like — the description is the interface when the caller is a model.

The one thing the adapters don't share is identity, and that's deliberate. siteTools({ siteUrl, userId }) takes the acting user. Over MCP that's req.oauth.user_id, the account that approved the OAuth client. In the dashboard it's req.user.id, the signed-in session. Either way a post records who wrote it.

The MCP server, and why it hosts its own OAuth

I wanted to add this site to the Claude app on my phone the same way I'd add any other connector: paste a URL, sign in, done. No tokens in a notes app, no secret pasted into a settings field I'd have to rotate by hand.

That means the site has to be an OAuth authorization server. Not a client — a server. src/oauth.js is 296 lines and implements what Claude actually asks for:

  • RFC 9728 protected-resource metadata and RFC 8414 server metadata, so the client can discover where to send people
  • RFC 7591 dynamic client registration, so Claude Code registers itself with no setup from me
  • Client ID Metadata Documents (SEP-991 — what claude.ai calls "Claude's published identity"), where the client_id is an https URL and the document there lists the redirect URIs
  • Authorization code with PKCE (S256), refresh tokens, RFC 7009 revocation

The flow from my end is: claude mcp add --transport http reynchr-blog https://reynchr.com/mcp, then /mcp, then a browser opens, I sign in with my actual account, I approve it once. On claude.ai it's Settings → Connectors → Add custom connector, paste the URL, leave the OAuth fields blank. The connector then shows up on my phone.

Connecting a Claude client, oncediscoveryapprove oncebearerClaude clientRFC 9728 + 8414metadataRegister: RFC 7591 orCIMDBrowser: sign in +TOTPCode + PKCEToken, tied to myaccountPOST /mcp
Connecting a Claude client, once
Connecting a Claude client, oncediscoveryapprove oncebearerClaude clientRFC 9728 + 8414metadataRegister: RFC 7591 orCIMDBrowser: sign in +TOTPCode + PKCEToken, tied to myaccountPOST /mcp
Connecting a Claude client, once

Two things I'd tell anyone implementing this side of it.

Tokens must remember who approved them. Every row in oauth_tokens carries a user_id. Disabling an account cuts its connected clients, because a token with no live user behind it is just a key with nobody's name on it. The admin has a Connected apps table listing every approved client, who approved it and when it was last used, with a revoke button.

Metadata-document fetching is an SSRF hole if you let it be. The server fetches a URL that a stranger supplies. Mine is https-only, no redirects (redirect: "manual"), a 5-second AbortSignal.timeout, a 64 KB cap on the body, a required client_id that matches its own URL, and a hostname blocklist for loopback and private ranges:

if (u.protocol === "https:")
  return !/^(localhost|127\.|10\.|192\.168\.|169\.254\.|\[::1\]|0\.)/.test(u.hostname)
      && !/^172\.(1[6-9]|2\d|3[01])\./.test(u.hostname);

Documents are cached in oauth_clients for an hour. When a re-fetch fails, a stale cached copy beats a hard failure — a client that already works shouldn't break because someone else's CDN had a bad minute.

The MCP endpoint itself is stateless Streamable HTTP: POST only, a fresh server and transport per request, both closed when the response closes. There's no session state to lose on a redeploy, which for a site that redeploys on every git push is the whole point.

The control room

/admin is server-rendered HTML with no client framework. Posts, products, announcements, your account, and users if you're an owner. Two roles: owner does everything, editor manages content and their own account. The last owner can't be demoted, disabled or deleted, which is the kind of guard you write once and then never think about until the day it saves you.

A few parts earned their complexity.

Announcements are a small state machine. Each one has a start, an end, an active switch and a priority, and the dashboard shows it as live, scheduled, expired or off — computed, not stored, so nothing has to run on a timer to flip a flag. The dismissal rule is the bit I'm happiest with: a dismissible announcement that a visitor closes is remembered in localStorage, keyed by its id and its updated_at. Edit the announcement and the key changes, so it comes back for everyone who had dismissed the old version. A non-dismissible one goes in sessionStorage and returns on the next visit.

Timezones, handled at the edge. Admin forms post a datetime-local value plus a tz_offset the browser fills in, and the server converts to UTC on the way in. Everything stored is UTC, everything displayed is local, and the conversion happens in exactly one function. The fallback when the browser doesn't say is 420 minutes, because I'm in Arizona and we don't do daylight saving.

Announcements inject into finished HTML. Public pages carry an <!--ANNOUNCEMENTS--> marker and every public response goes through one helper that swaps in the live ones. Admin pages don't use that helper, so the control room never shows a popup at you while you're editing it.

Two-factor, and the one line that could have undone it

TOTP is in src/totp.js — 67 lines, RFC 6238, base32 secrets, node:crypto only, no dependency. Enrolment shows a QR code, takes a confirming code, then prints eight recovery codes once and stores only their hashes. Passwords are scrypt with the parameters written into the hash string (scrypt$N$r$p$salt$hash) so I can raise them later without a flag day.

Two details worth copying. Verification records the time step it accepted in totp_last_step and refuses anything at or below it, so a code someone shoulder-surfed is dead the moment it's used. And a wrong code increments a counter on the challenge row; five strikes and the challenge is deleted and you start over at the password.

But the real lesson here isn't cryptographic. When I added MFA, there were two password forms on this site: /login for the dashboard and /oauth/login for the connector consent screen. I wired the challenge into the first one. The second one still called startSession directly — which is to say, the OAuth sign-in Claude uses was a complete bypass of the second factor I'd just added. The attacker's path wasn't the hard part of the crypto; it was the sign-in form I forgot I had written.

Every password form, one exitnoyescode acceptedPOST /logincompletePasswordLoginPOST /oauth/loginTOTP on?Start sessionChallenge: /login/mfaStart session
Every password form, one exit
Every password form, one exitnoyescode acceptedPOST /logincompletePasswordLoginPOST /oauth/loginTOTP on?Start sessionChallenge: /login/mfaStart session
Every password form, one exit

So now there's exactly one entry point, completePasswordLogin, both forms go through it, and that invariant is written down in CLAUDE.md where I'll see it next time:

Both password forms (/login and /oauth/login) must finish through completePasswordLogin, never startSession directly, or two-factor is bypassed.

If you add a second factor to anything, go count your login forms. Then count them again.

The AI console

The dashboard has a text box. I type "draft a post about shipping Hues 1.1, keep it a draft" or "announce the beta for the next week," and Claude does it with the same eleven tools, streaming each call and result into the page as it goes.

It's client.beta.messages.toolRunner({ stream: true, ... }) from the Anthropic SDK with model: "claude-opus-5", and the handler pushes server-sent events: text, tool_start, tool_result, done, error. The browser side, public/admin-ai.js, is 95 lines and just renders them.

What took the actual thought was the system prompt, which is less a prompt than a set of house rules:

  • Look before you change: list or get the current item first when editing, so you don't clobber copy
  • New posts are drafts unless I explicitly say publish
  • Never delete anything unless I asked to delete that specific thing in this conversation
  • Announcement dates are ISO-8601 with an offset, and I'm in Arizona at UTC-7
  • If a request is ambiguous in a way that matters — which product? publish or draft? — ask instead of guessing

That last one is the difference between a tool I trust and a tool I babysit. These are guardrails in the prompt rather than in code, and I'm honest with myself that that's a softer guarantee than a check in run() — but the destructive operations all need an explicit id or slug, which is a real floor under the soft ceiling.

Three implementation notes:

Cache the static part. The system prompt is two blocks: the long invariant one with cache_control: { type: "ephemeral" }, then a short dynamic one with the signed-in user, the site URL and the current time in Phoenix. Stable text first, breakpoint, then the stuff that changes per request. That ordering is the whole trick.

Inject the runner for tests. aiRunHandler({ siteUrl, createRunner, getClient }) takes both as parameters that default to the real SDK. The tests pass a fake runner, which is how there are HTTP-level tests for the SSE stream without an API key or a network call in CI.

Tool results get truncated at 4,000 characters before they go down the wire, and prior turns from the browser are capped at 24 and sanitized to plain strings. The browser is not a trusted source of conversation history.

What I'd carry to the next one

Write the registry before the transports. If I'd built the MCP server first and the dashboard console second, I'd have two tool lists drifting apart right now, and I'd be maintaining two sets of descriptions for the same eleven operations.

Migrations as an append-only array. MIGRATIONS in src/db.js is a list of SQL strings applied in order under a Postgres advisory lock, each in its own transaction, recorded in schema_migrations. Several instances can boot at once and exactly one applies each migration. Appending is allowed, editing an applied one never is. That's maybe 20 lines and it's never once been the reason a deploy went wrong.

Let the database hand you the types you want. One types.setTypeParser call at the top of db.js means timestamps arrive as ISO strings everywhere instead of Date objects, so templates, JSON responses and MCP tool output all agree without a single .toISOString() sprinkled through the app.

Test what you can't see. The tests are concentrated where eyeballs don't reach: TOTP step windows and replay, the MFA challenge flow, the SSE stream shape, and the diagram renderer that draws the pictures in this post. The HTML I can just look at.

The boring stack is the feature. Express, pg, server-rendered strings, no build step. The deploy is a git push; Railway injects DATABASE_URL and the app migrates itself on boot. There's exactly one interesting thing in this codebase and it's the tool registry. Everything else should be dull enough that I can come back in six months and still read it.

Chris