TL;DR: I added a Model Context Protocol server to my startup-launch platform so that an AI agent can create a listing, ship a release, and post a changelog on your behalf. Then I hit the wall every hosted MCP author eventually hits - the Claude Connectors Directory wants OAuth 2.1 with PKCE and Dynamic Client Registration, and I already had a boring static bearer token. Here's how I bolted a minimal OAuth Authorization Server onto an existing Auth.js app without throwing away the old token, plus the discovery-metadata handshake that actually makes Claude start the flow.
The idea: let the agent do the boring part
I run BetaFinds, a place to discover early-stage products. Every founder does the same chores after shipping: update the listing, write a "what's new" post, cut a release with downloadable binaries, upload screenshots. It's exactly the kind of repetitive, well-structured work an agent is good at.
So instead of "log into a dashboard," the pitch became: "Claude, publish my v2.1 release on BetaFinds" — and it happens.
To make that real, the platform needed to be something an AI agent can drive. That's what MCP is for.
The MCP server: smaller than you think
MCP over "streamable HTTP" is just JSON-RPC 2.0 over a single POST endpoint. You implement three methods and you have a working server:
-
initialize— handshake, advertise capabilities -
tools/list— describe your tools (JSON Schema in, JSON Schema out) -
tools/call— actually do the thing
My server exposes 10 tools: create_startup, update_startup, publish_update, create_release, upload_image, and friends. The whole thing is one Next.js route handler.
export async function POST(req: NextRequest) {
const user = await authenticateApiToken(req.headers.get("authorization"))
const { id, method, params } = await req.json()
switch (method) {
case "initialize": return rpc(id, { protocolVersion, capabilities, serverInfo })
case "tools/list": return rpc(id, { tools: TOOLS.map(describe) })
case "tools/call": {
if (!user) return unauthorized(id) // 👈 the interesting part, later
const tool = TOOLS.find(t => t.name === params.name)
return rpc(id, await tool.handler(user, params.arguments))
}
}
}
Gotcha #1: keep introspection unauthenticated
My first version required a token for everything, including initialize and tools/list. That broke discovery: registry scanners (Smithery, Glama, PulseMCP) hit initialize, got a 401, assumed a full OAuth flow existed, and bailed.
The fix: auth is only required for tools/call. initialize / tools/list / ping answer without a token so anyone — a scanner, a curious human, a directory crawler — can see what the server is before connecting. Nothing sensitive happens until a real action.
Getting listed (the easy distribution)
Registries were mostly painless. A server.json, a public repo, and a few forms got the server into the official MCP Registry, Smithery (quality score 90/100 after adding tool annotations + outputSchema), Glama (grade A), and mcp.so. Good for discovery, zero gatekeeping.
Then came the one that actually matters: the Claude Connectors Directory — visible to every Claude user inside claude.ai, Desktop, and mobile. That one has a gate.
The wall: the directory wants OAuth 2.1
My server authenticated with a static token: Authorization: Bearer blt_…. Great for claude mcp add --header, useless for the directory, which requires OAuth 2.1 + PKCE (S256).
Two options:
- A. Delegate to a vendor (Stytch/WorkOS turnkey MCP auth). ~2–4 days, but adds a dependency and an identity bridge.
-
B. Run a minimal Authorization Server myself. More code, but I already had Auth.js (login + sessions) and a token model. An OAuth access token is basically the token I already issue, just handed out through an
/authorizeflow.
I picked B, because the "identity bridge" in option A is free when you are the identity provider: on the consent screen the user is already logged into my app, so the token maps to their account with zero glue.
The rule I set for myself: the old blt_ token keeps working. OAuth is added next to it, not instead of it.
The discovery handshake (this is what makes Claude start)
An MCP client doesn't magically know you speak OAuth. It finds out from a specific 401. The one header that kicks off the whole dance:
function unauthorized(id: unknown) {
return Response.json(
{ jsonrpc: "2.0", id, error: { code: -32001, message: "Unauthorized" } },
{
status: 401,
headers: {
// RFC 9728 — this is the breadcrumb the client follows
"WWW-Authenticate":
`Bearer resource_metadata="https://betafinds.com/.well-known/oauth-protected-resource"`,
},
}
)
}
From there the client walks a chain of well-known documents:
-
Protected Resource Metadata (
/.well-known/oauth-protected-resource) — "here's the resource, and the authorization server that guards it." -
Authorization Server Metadata (
/.well-known/oauth-authorization-server) — endpoints +code_challenge_methods_supported: ["S256"].
// /.well-known/oauth-authorization-server
{
"issuer": "https://betafinds.com",
"authorization_endpoint": "https://betafinds.com/oauth/authorize",
"token_endpoint": "https://betafinds.com/api/oauth/token",
"registration_endpoint": "https://betafinds.com/api/oauth/register",
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Next.js note: App Router serves folders with a leading dot unreliably, so the real routes live in
app/well-known/...and arewritemaps/.well-known/...onto them.
Dynamic Client Registration: let Claude register itself
The directory (and MCP Inspector, and every generic MCP client) expects to register a client at runtime — RFC 7591. No pre-shared client IDs. The clients are public (PKCE, no secret), so registration is refreshingly small:
export async function POST(req: NextRequest) {
const { redirect_uris, client_name } = await req.json()
// exact-match allowlist later; validate scheme now (https, or http-localhost for Inspector)
if (!redirect_uris?.every(isValidRedirectUri)) return oauthError("invalid_redirect_uri")
const client = await prisma.oAuthClient.create({
data: { name: client_name ?? "MCP Client", redirectUris: redirect_uris, scope: "mcp" },
})
return corsJson({ client_id: client.id, token_endpoint_auth_method: "none", /* … */ }, { status: 201 })
}
Yes, open registration means anyone can create a client row. That's the intended shape for MCP — the security comes from PKCE + exact-match redirect URIs + user consent, not from gatekeeping registration.
Authorize + consent: reuse the session you already have
The authorization endpoint is where "I already have Auth.js" pays off. If the user isn't signed in, bounce them through the existing login and back:
const client = await prisma.oAuthClient.findUnique({ where: { id: clientId } })
if (!client || !client.redirectUris.includes(redirectUri)) {
return <AuthorizeError/> // never redirect to an unvalidated URI
}
if (codeChallengeMethod !== "S256" || !codeChallenge) {
redirect(errorBack("invalid_request")) // PKCE is mandatory
}
const session = await auth()
if (!session?.user) {
redirect(`/login?callbackUrl=${encodeURIComponent(currentUrl)}`) // ← Auth.js already supports this
}
return <ConsentForm client={client} user={session.user} .../>
On "Allow", a server action mints a short-lived (60s), single-use authorization code bound to client_id, redirect_uri, the PKCE code_challenge, and — crucially — session.user.id. That last binding is the whole "no identity bridge" trick: the token is for the consenting user, full stop.
The token endpoint: PKCE verify + refresh rotation
// authorization_code grant
const record = await prisma.oAuthAuthCode.findUnique({ where: { codeHash: sha256(code) } })
const bad =
record.expiresAt < new Date() ||
record.clientId !== clientId ||
record.redirectUri !== redirectUri ||
!verifyPkceS256(codeVerifier, record.codeChallenge)
// delete first — the delete IS the single-use gate against replay
const { count } = await prisma.oAuthAuthCode.deleteMany({ where: { id: record.id } })
if (bad || count === 0) return oauthError("invalid_grant")
PKCE verification is one line, and it should be timing-safe:
export function verifyPkceS256(verifier: string, challenge: string) {
const computed = createHash("sha256").update(verifier).digest("base64url")
return timingSafeEqual(Buffer.from(computed), Buffer.from(challenge))
}
The nice part: the access token is just my existing token
I didn't invent a new token store. An OAuth access token is a row in the same api_tokens table, with a few nullable columns added (type, expiresAt, refreshTokenHash, clientId). So the auth function barely changed — it now accepts any bearer, looks it up by hash, and honors expiry:
export async function authenticateApiToken(header: string | null) {
if (!header?.startsWith("Bearer ")) return null
const record = await prisma.apiToken.findUnique({
where: { tokenHash: sha256(header.slice(7)) },
select: { id: true, userId: true, expiresAt: true },
})
if (!record) return null
if (record.expiresAt && record.expiresAt < new Date()) return null // OAuth tokens expire; blt_ don't
return { userId: record.userId, tokenId: record.id }
}
Static blt_ tokens have expiresAt = null and keep working forever. OAuth access tokens expire in an hour and are rotated via refresh (in-place: minting a new access + refresh invalidates the old pair immediately). One code path, two token types, no duplication.
Gotcha #2: test it like the reviewer will — end to end, in the real language
I was sure it worked. My local curl tests were green (16/16: single-use codes, PKCE negative, redirect-URI mismatch, refresh rotation, the works). Then I drove the actual browser flow against production with a real account — and the consent screen greeted an English user in Russian.
The platform is bilingual; the consent strings went through the translation helper but I'd never added the English entries, so they fell back to the source language. Every curl test in the world wouldn't have caught it, because the bug was in the one surface a machine doesn't read: the human consent screen — the exact thing a directory reviewer stares at.
Lesson: for an auth flow, the end-to-end browser run isn't optional. Drive login → consent → code → token → tool call, and look at the screen.
Where it stands
The connector is live on both my domains, the old token still works, and a real Claude client can now discover → register → consent → call tools over OAuth. The directory submission itself turns out to be gated to Team/Enterprise orgs, so that last step is a support-ticket conversation — but the hard part, the standards-compliant AS, is done and it's maybe ~300 lines.
If you have a hosted MCP server and you're eyeing the Claude directory: you probably don't need a vendor. If you already have login and a token model, a minimal OAuth 2.1 AS is a weekend, and the users you already have become the identities you already trust.
Building something with MCP? I'd love to hear what you're connecting — drop it in the comments.
Top comments (0)