The MCP endpoint runs on @modelcontextprotocol/sdk v1.30 — McpServer plus
WebStandardStreamableHTTPServerTransport, driven directly:
// apps/web/src/lib/mcp/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
The protocol has since split that package into @modelcontextprotocol/core,
@modelcontextprotocol/client, and @modelcontextprotocol/server, all at 2.0.0.
The v2 server offers createMcpHandler, which owns the HTTP shape — including
legacy: "reject" for clients still speaking the pre-Streamable transport.
Why this is a separate ticket
The better-auth 1.7 upgrade guide's MCP section says to adopt it:
Use the version 2 createMcpHandler with legacy: "reject", wrap it with
requireMcpAuth, and expose only POST.
That instruction is addressed to applications migrating off better-auth's
in-core MCP client and server adapters, which 1.7 deleted. We never used
those — we drive the official SDK ourselves — so nothing was removed under us
and #679 deliberately left this alone. Two further reasons it did not belong in
that PR:
requireMcpAuth cannot wrap our handler on any SDK version. It verifies a
JWT against a JWKS URL, which the app cannot fetch when its public
address is a published container port or a proxy, and it assumes a single
credential where /api/mcp tries an API key first and an OAuth token second.
verifyMcpAccessToken in packages/lib/src/auth/server.ts exists for exactly
that reason and stays either way.
- "Expose only
POST" follows from the v2 handler's stateless mode. Our
transport is already stateless (enableJsonResponse: true, no session id) and
owns its own verb table, so GET and DELETE reach it and are answered per
the protocol.
v1.30 is not deprecated and works: the e2e suite drives it with the official
client and the browser OAuth flow end to end. So this is upkeep, not a fix.
What it would touch
apps/web/src/lib/mcp/server.ts — McpServer and the transport
apps/web/src/lib/mcp/tools/* — 7 files; tool registration shape
apps/web/src/lib/mcp/__tests__/server.test.ts — Client + InMemoryTransport
e2e/tests/shared/mcp.spec.ts — Client + StreamableHTTPClientTransport
apps/web/src/routes/api/mcp.ts and api/mcp/$.ts — only if the verb table moves
Auth is untouched: resolveMcpAuth hands a Principal to handleMcpRequest,
and nothing below that boundary knows which SDK is underneath.
Worth checking while there
- The v1 bundle is ~1 MB in the server output
(.output/server/_libs/@modelcontextprotocol/sdk+[...].mjs). Whether the split
packages shrink that is worth measuring, not assuming.
- Whether v2 changes the negotiated protocol version, and whether that interacts
with MCP 2026-07-28's Client ID Metadata Documents — better-auth offers
cimd() for that, which we do not use (we run dynamic client registration
instead, which the guide sanctions).
Done when
The e2e MCP suite passes unchanged — the official client connecting, listing
tools, reading a brand's numbers, and the full browser OAuth flow — with no
change to lib/mcp/auth.ts or packages/lib/src/auth/server.ts.
The MCP endpoint runs on
@modelcontextprotocol/sdkv1.30 —McpServerplusWebStandardStreamableHTTPServerTransport, driven directly:The protocol has since split that package into
@modelcontextprotocol/core,@modelcontextprotocol/client, and@modelcontextprotocol/server, all at 2.0.0.The v2 server offers
createMcpHandler, which owns the HTTP shape — includinglegacy: "reject"for clients still speaking the pre-Streamable transport.Why this is a separate ticket
The better-auth 1.7 upgrade guide's MCP section says to adopt it:
That instruction is addressed to applications migrating off better-auth's
in-core MCP client and server adapters, which 1.7 deleted. We never used
those — we drive the official SDK ourselves — so nothing was removed under us
and #679 deliberately left this alone. Two further reasons it did not belong in
that PR:
requireMcpAuthcannot wrap our handler on any SDK version. It verifies aJWT against a JWKS URL, which the app cannot fetch when its public
address is a published container port or a proxy, and it assumes a single
credential where
/api/mcptries an API key first and an OAuth token second.verifyMcpAccessTokeninpackages/lib/src/auth/server.tsexists for exactlythat reason and stays either way.
POST" follows from the v2 handler's stateless mode. Ourtransport is already stateless (
enableJsonResponse: true, no session id) andowns its own verb table, so
GETandDELETEreach it and are answered perthe protocol.
v1.30 is not deprecated and works: the e2e suite drives it with the official
client and the browser OAuth flow end to end. So this is upkeep, not a fix.
What it would touch
apps/web/src/lib/mcp/server.ts—McpServerand the transportapps/web/src/lib/mcp/tools/*— 7 files; tool registration shapeapps/web/src/lib/mcp/__tests__/server.test.ts—Client+InMemoryTransporte2e/tests/shared/mcp.spec.ts—Client+StreamableHTTPClientTransportapps/web/src/routes/api/mcp.tsandapi/mcp/$.ts— only if the verb table movesAuth is untouched:
resolveMcpAuthhands aPrincipaltohandleMcpRequest,and nothing below that boundary knows which SDK is underneath.
Worth checking while there
(
.output/server/_libs/@modelcontextprotocol/sdk+[...].mjs). Whether the splitpackages shrink that is worth measuring, not assuming.
with MCP 2026-07-28's Client ID Metadata Documents — better-auth offers
cimd()for that, which we do not use (we run dynamic client registrationinstead, which the guide sanctions).
Done when
The e2e MCP suite passes unchanged — the official client connecting, listing
tools, reading a brand's numbers, and the full browser OAuth flow — with no
change to
lib/mcp/auth.tsorpackages/lib/src/auth/server.ts.