Agent connections
The User and Admin MCP servers in the Next.js + Cloudflare D1 starter: endpoints, tools, OAuth behaviour and where the code lives.
This kit ships two remote MCP servers. The web Worker serves the User MCP and the admin Worker serves the Admin MCP. Both use the existing Better Auth login, store their OAuth records in D1 and are read-only in v5. The shared pages cover the general flow: user and admin MCPs, connect an agent and tools and permissions.
The mcp_records migration (apps/database/migrations/0010_famous_groot.sql) is part of the normal migration set and must be applied before the /mcp routes serve traffic. pnpm db:migrate applies it locally.
Endpoints
| Connection | Served by | Settings page | Server URL | Live demo |
|---|---|---|---|---|
| User | web Worker (apps/web) | <web origin>/mcp/connections/user | <web origin>/mcp/user | https://nextjs-d1.usenextbase.com/mcp/user |
| Admin | admin Worker (apps/admin) | <admin origin>/mcp/connections/admin | <admin origin>/mcp/admin | https://nextjs-d1-admin.usenextbase.com/mcp/admin |
Each origin is that Worker's NEXT_PUBLIC_SITE_URL: http://localhost:3000 for the web app and http://localhost:3001 for the admin app during development. The web sidebar has an Agent connections link for every user, and the admin sidebar has an Admin agent connections link. Both open in a new tab because the connection pages are rendered by the MCP handler, not by the Next.js app shell.
The same handler runs in both Workers. ADMIN_APP=true makes it register only the admin audience, so the web origin never answers /mcp/admin and the admin origin never answers /mcp/user.
Opening a server URL in a browser without a token returns a 401 with a WWW-Authenticate challenge pointing at the resource metadata. That is expected.
Tools
| Server | Tool | Scope | What it returns |
|---|---|---|---|
| User | account_context | user:read | The signed-in user's ID and email plus the approved workspace |
| User | projects_list | user:read | Projects in the approved workspace only, newest first |
| Admin | admin_users_list | admin:read | ID, name, email and role for users across the app |
| Admin | admin_workspaces_list | admin:read | ID, name and slug for workspaces across the app |
List tools accept a limit between 1 and 50, defaulting to 20. Every tool is annotated read-only, non-destructive and idempotent. There are no write tools in this kit; projects_create exists only in the Convex kits.
Each call reloads the live actor from D1: the session must still exist and be unexpired, the user must be verified and not banned, and the session must not be an impersonation session. projects_list additionally re-checks the caller's membership and projects.read permission in the approved workspace. The admin tools re-check user.role = 'admin' on every request and refuse to run unless ADMIN_APP is true, so removing the role ends access immediately.
Consent and workspace selection
The consent page lists only workspaces where the account has projects.read. If there are none, the page says to create or join a workspace first and offers no approve button. Administrators who connect to the User MCP get a normal user connection; their token doesn't work at the admin endpoint, which is on a different origin anyway.
Consent and disconnect forms require same-origin submission and a session-bound CSRF value. The consent page disallows framing. Client names are self-reported by the client and are marked unverified; check the callback address and the server shown on the page.
Each account can have at most 20 active connections. Disconnect from the settings page when you no longer need one.
OAuth details for this kit
- Discovery, per origin:
/.well-known/oauth-protected-resource/mcp/useron the web origin,/.well-known/oauth-protected-resource/mcp/adminon the admin origin, and/.well-known/oauth-authorization-server/oauthon both. The issuer is<origin>/oauthof the Worker you connect to. - Endpoints on each origin:
/oauth/register,/oauth/authorize,/oauth/token,/oauth/revoke. Consent is at/mcp/authorize. - Public clients with dynamic registration, authorization code, mandatory PKCE S256, exact callback and resource matching. Confidential clients and client ID metadata documents are not supported.
- Callback URLs must be HTTPS, except HTTP loopback on
localhost,127.0.0.1and::1. - Lifetimes: pending consent 10 minutes, authorization codes 5 minutes, access tokens 15 minutes, refresh up to 30 days but never longer than the native session that approved the connection.
- Refresh tokens rotate. Reusing an old one revokes the whole connection family.
- Browser origins allowed: that Worker's
NEXT_PUBLIC_SITE_URL,https://claude.aiandhttps://chatgpt.com. Clients without anOriginheader are accepted.
If a user isn't signed in when consent opens, they are sent to /login?next=/mcp/authorize?request_id=... on the same origin and returned to the same request after signing in. Both login pages do a full-document navigation for /mcp/ destinations so the consent page loads outside the app shell.
Where the code lives
core.tsis the provider-agnostic protocol: OAuth endpoints, consent HTML, token handling, rate limits, the connection pages and tool registration with the official@modelcontextprotocol/sdk(Streamable HTTP, stateless JSON responses).adapter.tsplugs in this kit: it resolves the native Better Auth session, loads the live actor and workspaces from D1 with the sharedcan()permission check, picks the audience fromADMIN_APP, and implements the four tool queries.d1-store.tsstores registrations, pending consents and credentials in themcp_recordstable using insert-or-nothing and compare-and-set updates, so concurrent consent, code and refresh transitions can't be duplicated.- The route files in both apps export the same
handleMcphandler forGET,POST,DELETEandOPTIONS. The admin app has no copy of the MCP code; itstsconfig.jsonmaps@/*to./src/*and then../web/src/*, so it importsapps/web/src/mcp/adapter.tsdirectly.
Adding a tool safely
- Add the data access in
adapter.tsand keep the permission check inside it. Load the actor withliveActorand check the workspace membership or admin role in the query itself, asprojectsandadminUsersdo. Admin queries should also requireenv.ADMIN_APP === "true". - Register the tool in
core.tsinside the correct audience branch. User tokens never see admin tools and vice versa. - Treat tool arguments as untrusted. A
workspaceIdin the input is not proof of access; useauth.grant.workspacefor user tools. - If the tool writes data, don't attach it to
user:readoradmin:read. Introduce an explicit write scope, add consent wording for it and keep the read-only annotations accurate. - Add cases to
core.test.tsandadapter.integration.test.ts. The integration test creates isolated D1 databases with Miniflare and exercises real Better Auth sign-up and login. - Run
pnpm verify:localbefore deploying, and apply any new migration withpnpm db:migrate:productionbefore shipping the Workers.
Authentication
How Better Auth is configured in the Next.js + Cloudflare D1 starter, how admins are assigned, where the admin app lives, and what protects application data.
Project structure
The five apps in the Next.js + Cloudflare D1 monorepo, the key config files, and where to make each kind of change.