Building the /learn System + MCP Content Tools — How We Did It
An end-to-end build record: the learning section, its MCP tools, and OAuth for Cowork
Most "how we built it" write-ups give you the final architecture and skip the part you actually need: the forks in the road. This one is the opposite. It's the honest build log of Rian Infotech's learning section (/learn) — every decision, why we made it, and the bugs that bit us twice.
By the end you'll know how we shipped three connected things, and the reasoning you can reuse on your own build:
/learn— a public, SEO-friendly content system with a soft email gate you can switch off per guide.- An admin at
/admin/learnto write and publish guides, with AI helping where it earns its keep. - MCP tools + OAuth so an AI agent can draft guides for you — and actually connect inside Cowork.
Stack: Next.js 16 (App Router) · React 19 · Supabase (Postgres + RLS) · Tailwind · OpenAI · Railway · a Streamable-HTTP MCP server (blog-mcp).
If MCP is new to you, start with MCP, Explained Simply. For the war stories behind running one, see MCP in Production — Lessons From the Trenches. This guide is the third in that series — the build story.
One idea ties the whole thing together, so keep it in your back pocket as you read:
The preview earns the traffic. The gate earns the lead. The admin and MCP earn the time.
Everything below is just that sentence, applied.
Part 1 — The /learn content system
It started with a simpler question: where does the content live?
Our first version kept each guide as a Markdown file in the repo. Clean, version-controlled, easy. Then the real requirement showed up: a non-developer wants to publish a guide without waiting for a deploy. That one sentence ended the file era.
So content moved into a single Postgres table, learning_guides. A thin data layer (lib/learn/db.js) reads it; the page renders it. Here's the shape that matters:
| column | purpose |
|---|---|
slug (unique) | the URL → /learn/<slug> |
preview_md | free Markdown, always shown |
body_md | the rest — gated when gated = true |
gated (bool) | email gate on/off, per guide |
gate_bullets (jsonb) | the benefit bullets on the gate |
status | Draft or Published |
Lesson: start with the simplest store that proves the idea (files). Switch to a database the moment a non-developer needs to edit. That's the real trigger — not scale, not elegance.
The soft gate — and the SEO bargain hiding inside it
Here's the tension every gated-content site faces: the email gate that grows your list is the same wall that hides you from Google.
We split the difference with a soft gate. preview_md renders for everyone — humans and crawlers alike. body_md appears only after someone submits an email (and we remember it in localStorage so they never re-enter it). Flip gated off and the body just… shows.
The reason this is safe — and not a Google penalty — is one principle:
We show crawlers exactly what a logged-out human sees: the preview and the gate. Never a secret full-text version stuffed for bots. That's the line between "gating" and "cloaking."
The honest trade-off: gated body_md doesn't get indexed. So we made the preview carry real weight — the full "what and why" lives there, and only the hands-on payoff sits behind the gate.
One sneaky CSS bug worth your time
We styled inline code blue with Tailwind Typography's prose-code. Looked great. Then someone opened a guide on their phone and the ASCII diagrams had vanished — blue text on a dark-navy code block.
The catch: prose-code matches every code, including the ones inside <pre>. So our inline color leaked into code blocks. The fix is to scope it:
/* inline code only — not code inside <pre> */
[&_:not(pre)>code]:text-themeColor
/* block code gets its own light-on-dark treatment */
[&_pre]:bg-slate-900 [&_pre_code]:text-slate-100
Lesson: a utility that matches "all
code" will find thecodeyou forgot about. Scope first, admire later.
The admin: Markdown beats a fancy editor here
/admin/learn lists guides; /admin/learn/edit is the form — and it's deliberately just Markdown with a Write/Preview toggle, not a rich WYSIWYG editor.
That feels like a step backward until you try to put an ASCII diagram or a code block into a rich editor. WYSIWYG fights you on exactly the things technical writing needs most.
Decision — right tool for the job. Markdown + live preview wins for dev-flavored content. Rich editors win for prose-heavy marketing pages. Don't pick one editor for both.
Where we let AI do the boring part
The gate sells locked content with three benefit bullets. Hardcoding them was obviously wrong — a guide about webhooks shouldn't promise to teach you "MCP." So the bullets are per-guide, and /api/admin/learn/generate-bullets asks OpenAI to write them from the guide's own content. There's a "✨ Generate with AI" button; leave the field empty and they're written automatically on save.
One gotcha that costs people an afternoon — the GPT-5 family are reasoning models with different parameters:
// GPT-5 / o-series reject `temperature`; use these instead
const isReasoning = /^(gpt-5|o\d)/i.test(model);
const params = isReasoning
? { max_completion_tokens: 800, reasoning_effort: "low", verbosity: "low" }
: { max_tokens: 800, temperature: 0.7 };
We also ask for response_format: { type: "json_object" } and keep a line-split fallback parser, because models occasionally return prose around the JSON.
"Someone will type a fake email" — so how do we check?
This question drove the whole lead flow. We deliberately didn't reach for OTP (emailing a verification code). Instead, server-side in /api/learn/lead:
// 1. format → 2. real mail server? → 3. not disposable?
const mx = await dns.resolveMx(domain).catch(() => null);
if (!mx?.length) return reject("no mail server for that domain");
if (DISPOSABLE.has(domain)) return reject("disposable address");
Decision — MX + disposable block, not OTP. OTP proves ownership but needs an email-sending provider we don't run, and it adds friction that quietly kills top-of-funnel conversion. MX + blocklist is free, instant, no new infra, and stops ~90% of junk. The honest limit: a real domain with a fake mailbox (
asdf@gmail.com) still slips through. Only OTP catches that — and we decided that 10% wasn't worth the friction.
Small bonus scar: leads.name was NOT NULL, but our form makes name optional. Empty inserts failed until we fell back to the email's local-part as the name.
Telling Google and the AIs what the page is
Every guide emits TechArticle + BreadcrumbList JSON-LD; the hub page emits CollectionPage + ItemList. Add canonical, OpenGraph, Twitter cards, keywords.
This isn't just for Google's rich results anymore. AI answer engines — ChatGPT, Perplexity, AI Overviews — lean on clear, structured, well-described content when they decide what to cite. And since only your preview is visible to them, the preview plus its schema are your citation surface. (Yet another reason to make the preview substantial.)
Watch out: our root layout sets
title.template = "%s | Rian Infotech". Page titles must not repeat the brand, or you ship "… | Rian Infotech | Rian Infotech."
The decision we were glad we made reversible
Midway through, the call came: "don't track email for now." The tempting move is to rip the gate out. We didn't. We added a gated boolean and a one-click 🔓/🔒 toggle in the admin, and opened the existing guides.
Decision — toggle, not delete. "For now" is a synonym for reversible. The toggle preserved every piece of work — the gate UI, the email verification, the AI bullets — behind a single switch, per guide. And ungating had a silver lining: the full guide is now indexed, which is strictly better for SEO. What looked like undoing a feature was actually an SEO win plus a pause on lead capture.
(Implementation note: when gated = false, GuideReader early-returns the full content — after all hooks run, so you don't trip the Rules of Hooks.)
Guides that introduce each other
Every guide automatically lists the other published ones under "Continue learning." No config — publish a second guide and the two link themselves. Good for SEO (internal links), better for readers (they keep going).
Gotcha — dynamic data vs. static rendering. A freshly published guide has to appear without a redeploy, so these pages use
export const dynamic = "force-dynamic"(SSR per request). WithgenerateStaticParamsthe new guide would hide until the next build. And yes — dynamic SSR is still fully indexable.
Part 2 — Teaching an AI agent to write guides
Now the fun part. We wanted to say "draft a beginner guide about webhooks" and have a Draft quietly appear in /admin/learn, ready for a human to review and publish.
The instinct is to spin up a new service. We didn't.
Decision — extend
blog-mcp, don't build a new server. It already had the Railway deploy, per-user token auth, and the Supabase connection. We added six tools —create / list / get / update / set_status / delete_learning_guide— and that was it. No new service, no new auth, one data layer feeding the site and the agent.
One subtlety: blog-mcp talks to Supabase with the service-role key, which bypasses Row-Level Security. So the database's RLS policies don't protect these writes — the code has to. Every write handler re-checks isAdminLevel(ctx.user.permission), mirroring the RLS policy by hand.
The bug that bit us twice — Node 22. Testing locally on Node 20 crashed instantly: "Node.js 20 detected without native WebSocket support."
@supabase/supabase-jsRealtime needs nativeWebSocket, which lands in Node 22+. Our Dockerfile already pinsnode:22-alpine; locally we just ran on Node 24. This was the exact crash from the Trenches guide, met all over again. Burn it in: anything using supabase-js → Node 22+.
Part 3 — The last mile: OAuth, so it actually works in Cowork
Here's where a "done" project turned out to have one more boss fight.
blog-mcp authenticates with a static bearer token (rmcp_live_…). In Claude Code or Desktop that's perfect — you pass it as a header and you're in. But Cowork's "Add custom connector" dialog only gives you a URL and optional OAuth Client ID/Secret. There is nowhere to paste a bearer token.
So when Claude hits our URL, this happens:
Claude → GET /mcp → 401
→ "any OAuth metadata here?" → none found
→ gives up. Connector fails.
The connector flow simply is OAuth. A bearer-only server can't play.
Decision — do OAuth properly, not the URL hack. We could have accepted the token as a
?token=query param. Five-minute fix — and it leaks your token into URLs and server logs forever. We took the real path instead.
The flow we built
blog-mcp became its own little OAuth authorization server. The whole dance:
Claude (Cowork) blog-mcp
| |
|── GET /mcp ───────────────────────────▶|
|◀── 401 + WWW-Authenticate: ────────────| "here's where to discover me"
| resource_metadata=".../.well-known/oauth-protected-resource"
| |
|── GET /.well-known/* (discovery) ─────▶| RFC 9728 + RFC 8414
|◀── authorize/token/register, S256 ─────|
| |
|── POST /register (auto) ──────────────▶| RFC 7591 → client_id
|◀── client_id ──────────────────────────| (user leaves ID/Secret blank)
| |
|── GET /authorize ─────────────────────▶| consent page:
| | user PASTES their rmcp_live_ token
|◀── one-time code (bound to PKCE) ───────|
| |
|── POST /token (code + PKCE verifier) ─▶| verify S256 + redirect, one-time
|◀── access_token ───────────────────────|
| |
|── GET /mcp (Bearer access_token) ─────▶| ✅ tools appear
Five standards do the work: the WWW-Authenticate breadcrumb on the 401, protected-resource metadata (RFC 9728), authorization-server metadata (RFC 8414), Dynamic Client Registration (RFC 7591), and PKCE (S256).
And here's the trick that made it small:
The access token we hand back is the user's existing
rmcp_live_token. Our/mcpbearer auth never changed. OAuth turned out to be nothing more than a standards-compliant way to capture and deliver a token the user already had. Minimum new surface, maximum reuse.
Connecting it (the 30-second version)
- Create a token at
/admin/mcp-tokens. - Cowork → Add custom connector → paste the URL (
…/mcp), leave the OAuth fields empty → Add. - On the consent page, paste your token → Authorize. The tools show up.
A couple of scars from this stretch:
Body parsing.
/tokenspeaksapplication/x-www-form-urlencodedbut/registerspeaks JSON — Express needs bothexpress.json()andexpress.urlencoded(). And setapp.set("trust proxy", true), or behind Railway's TLS proxy your metadata URLs come out ashttp://and discovery silently breaks.
In-memory stores. Registered clients and auth codes live in a
Map. Fine for one Railway instance — codes are one-time and expire in 5 minutes. Scale to multiple replicas and you'll need to move them into Supabase.
If you're rebuilding this, here's the short version
The eight things we'd tell our past selves:
- Reversibility is a feature. "For now" decisions should be a toggle, not a rewrite — you'll flip them more than once.
- State the SEO trade-off out loud. Soft gate = preview indexed, body hidden. Ungated = everything indexed. Pick per goal, and make the preview carry the weight either way.
- Hold the service key? Authorize in code. Service-role bypasses RLS, so your server is the access check now.
- One data layer, many mouths. The same tables feed the site, the public reads, and the MCP. One source of truth, zero sync code.
- Pin Node to 22+ anywhere near supabase-js. We hit the missing-
WebSocketcrash twice. Don't make it three. - "Build succeeded" ≠ "it works." After every deploy, curl the live URL for a real marker — content present? schema present? does the 401 carry
WWW-Authenticate? - OAuth can be a thin shim. Don't invent a token system; capture the credential the user already has and hand it back through the standard flow.
- Flaky network? Take the git path.
railway uptimed out repeatedly on a weak connection; small commits to GitHub built far more reliably.
Written from the actual build — every fork and every bug here genuinely happened. The value was never the final code; it was the choices along the way. Next time, this doc is the starting line, not the finish.
Continue learning
More practical guides from Rian Infotech.
MCP in Production — Lessons From the Trenches
Hard-won, hands-on lessons from building and shipping real MCP (Model Context Protocol) servers in production: transports, auth, the errors that bit us, what an AI agent experiences using MCP tools, and the principles that tie it together.
MCP, Explained Simply
Understand MCP from zero — the “USB-C for AI tools” — then build a working MCP server in about 10 minutes. No prior AI or agent knowledge needed.