Your MCP server can be its own OAuth server
I needed OAuth for Perch, an MCP connector that lets an agent search and book stays. MCP clients arrive with no credentials, so the server has to be able to issue them. I asked for options and got three: Clerk, Stytch, WorkOS.
All three were paid hosted vendors. The right answer wasn't on the list.
The reasoning was correct and the shape was wrong
The starting premise was fine. Hand-rolling OAuth 2.1 with dynamic client registration is a bad trade — it's a spec with a real attack surface and no upside to implementing yourself.
But that premise got collapsed into a binary: don't build it, therefore buy it. Three options existed, not two.
| Option | Verdict |
|---|---|
| Hand-roll OAuth 2.1 + registration | Genuinely a bad trade |
| A library that implements the spec | The right answer, nearly skipped |
| Paid hosted vendor | What got reached for by reflex |
The middle option is the one that disappears. "Don't roll your own auth" is a good rule, and like most good rules it gets compressed in transit until it arrives as "pay someone". Using a library that implements a standard is not rolling your own standard. That distinction is the whole article.
What the library actually is
Better Auth's mcp() plugin is not a helper that talks to an authorization server. It is the authorization server. It serves the OAuth 2.1 endpoints under /oauth2/* — authorize, token, register, userinfo — with PKCE, RFC 8414 authorization-server metadata and RFC 9728 protected-resource metadata.
That's the entire thing I was about to pay for.
export const auth = betterAuth({
baseURL,
secret: process.env.BETTER_AUTH_SECRET,
database: database(),
emailAndPassword: { enabled: true },
plugins: [
jwt(),
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: `${baseURL}/api/mcp`,
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
}),
],
});
mcp() is the OAuth provider, so there is no separate provider plugin to add. jwt() is there to supply the stable signing keys and the JWKS endpoint it needs.
What it cost
A database. Postgres in production, SQLite locally, chosen by whether DATABASE_URL is set. That wasn't new cost — the user-to-booking map needed Postgres anyway before running more than one instance, so two deferred items collapsed into one.
Self-hosting bought three things the vendors couldn't. The user table is mine, which matters for a paid protection tier later. There's no per-user billing ceiling on a product whose whole point is a high volume of small bookings. And nothing in the consent screen carries someone else's brand — the user is granting access to Perch, not to Perch-via-a-logo-they've-never-seen.
Chasing it also surfaced a bug that had nothing to do with auth: the MCP handler was written against v1 of the SDK while the bridge package peer-depended on v2. v2 ships its own handler, so the bridge came out entirely. That bug was sitting there the whole time and only fell out because I was touching the layer above it.
The part I'd flag if you copy this
Two honest caveats.
Unauthenticated dynamic client registration is an abuse surface. I took it deliberately: a registered client still can't reach a single tool until a human completes login and consent, so the blast radius is rows in a table, not bookings. Know that you're accepting it.
More importantly, the MCP spec revision of 2026-07-28 deprecates dynamic client registration outright, in favour of Client ID Metadata Documents. It stays available for backwards compatibility with authorization servers that don't support CIMD, and the deprecation window is twelve months — but if you're standing this up now, you're standing it up on a clock. The companion package exists; I left it out because its optional framework peer pulled in a major version of vite that collided with my test runner's. That's a dependency problem, not an architecture one, and it's the next thing I owe this server.
Neither caveat changes the conclusion. The conclusion is that I almost paid a monthly fee to avoid writing fourteen lines of configuration, because a rule I believe in got heard as a different rule I don't.