Agent connections
The User MCP and Admin MCP in the TanStack + Cloudflare D1 starter: endpoints, tools, OAuth behaviour and where the code lives.
This kit exposes two independent remote MCP servers, one per Worker: the web Worker serves the User MCP and the admin Worker serves the Admin MCP. Both use your existing Better Auth accounts and both are read-only in v5. The code ships on main in apps/web/src/mcp/, with the mcp_records table in migration 0025_user_admin_mcp.sql. Read user and admin MCPs for the concepts and connect an agent for the client-side steps.
Where users manage connections
| Connection | Settings page | Live demo server |
|---|---|---|
| User | /mcp/connections/user on the web app | https://tanstack-d1.usenextbase.com/mcp/user |
| Admin | /mcp/connections/admin on the admin app | https://tanstack-d1-admin.usenextbase.com/mcp/admin |
"Agent connections" is the first item in the signed-in navigation of the web app and is also linked from /user/settings. Administrators open the admin origin for the Admin MCP. Each page shows the server address to copy, lists that account's connections for that server, and offers Disconnect.
Your own deployment uses its own origins: the User MCP is the web Worker's SITE_URL + /mcp/user and the Admin MCP is the admin Worker's SITE_URL + /mcp/admin. The settings pages print the exact value.
One audience per origin
apps/web/src/mcp/adapter.ts serves only the user audience when ADMIN_APP is unset and only the admin audience when ADMIN_APP is true, which is how the admin Worker is configured. Requesting the other audience returns 404: https://tanstack-d1.usenextbase.com/mcp/admin is a 404, while https://tanstack-d1-admin.usenextbase.com/mcp/admin answers with the expected 401 challenge.
Tools and permissions
| Server | Tool | Scope | What it returns |
|---|---|---|---|
| User | account_context | user:read | The signed-in user's ID and email and the workspace approved for this connection. |
| User | projects_list | user:read | Projects in the approved workspace only, newest first. Optional limit from 1 to 50, default 20. |
| Admin | admin_users_list | admin:read | A bounded list of users (id, name, email, role). Optional limit, same bounds. |
| Admin | admin_workspaces_list | admin:read | A bounded list of workspaces that are not being deleted. Optional limit, same bounds. |
Every tool is annotated read-only, non-destructive and idempotent. There is no projects_create in this kit. A user connection selects exactly one workspace; being an application administrator does not widen it, and a user token is rejected at the admin endpoint.
Before answering, each tool call reloads the live session: the session must exist and not be expired, must not be an impersonation session, and the user must be verified and not banned. projects_list additionally resolves the workspace through requireWorkspaceContext and checks workspace.read. The admin tools re-check role = 'admin' and ADMIN_APP === "true" on every call, so removing the role ends access immediately.
OAuth behaviour in this kit
Both Workers handle /mcp/*, /oauth/* and /.well-known/oauth-* before React Router sees the request. Each origin is its own authorization server; a token issued by one is not valid at the other.
- Discovery:
/.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 that Worker'sSITE_URL+/oauth. - Endpoints:
/oauth/register,/oauth/authorize,/oauth/token,/oauth/revoke, and the consent page at/mcp/authorize. - Public clients only, with dynamic client registration, authorization code and mandatory PKCE S256. Confidential clients and client ID metadata documents are not supported.
- Callback URLs must be HTTPS, except HTTP loopback on
localhost,127.0.0.1or::1. - If you are not signed in, consent redirects to
/login?next=/mcp/authorize?...on the same origin. Login, sign-up and email verification preserve that return path and load the consent page as a full document. On the admin origin that is the admin login page. - The consent screen shows the client name (marked unverified), callback address, server, permission and your email, and lets you pick a workspace for user access. If you have no workspace it asks you to create one first.
- Pending consent lasts 10 minutes, codes 5 minutes, access tokens 15 minutes. A connection can refresh for up to 30 days but never longer than the sign-in session that approved it. Refresh tokens rotate and reuse revokes the whole family.
- Browser origins allowed: that Worker's
SITE_URL,https://claude.aiandhttps://chatgpt.com. Desktop and server clients without anOriginheader are accepted. - Each account can hold up to 20 active connections. Consent and disconnect need a same-origin form post with a session-bound CSRF value.
Opening an MCP URL in a browser without a token returns a 401 with a WWW-Authenticate challenge pointing at the resource metadata. That is expected.
Storage
OAuth registrations, pending consents and delegated credentials live in one additive table, mcp_records, created by apps/database/migrations/0025_user_admin_mcp.sql and mirrored in apps/database/src/schema/core.ts. Both Workers read and write the same table through the shared APP_DB. Secrets are stored only as SHA-256 hashes. pnpm db:migrate applies the migration locally and pnpm db:migrate:preview or pnpm db:migrate:production remotely.
Where the code lives
| File | Role |
|---|---|
apps/web/src/mcp/core.ts | Provider-neutral protocol: discovery, OAuth endpoints, consent HTML, connection pages, token lifecycle and the MCP server with its tools. isMcpPath decides which paths it owns and the adapter's audiences decide which server an origin serves. |
apps/web/src/mcp/adapter.ts | The D1 and Better Auth adapter: liveActor, workspaces, projects, adminUsers, adminWorkspaces and the native session lookup. Picks the audience from ADMIN_APP. |
apps/web/src/mcp/d1-store.ts | Compare-and-set storage over mcp_records. |
apps/web/workers/app.ts | Routes /mcp/*, /oauth/* and /.well-known/oauth-* to handleMcp on the web Worker. |
apps/admin/workers/app.ts | Imports the same handleMcp and isMcpPath so the admin Worker serves the Admin MCP. |
apps/web/src/mcp/core.test.ts | Protocol, PKCE, CSRF, replay, concurrency, expiry, membership and revocation tests. |
apps/web/test/mcp.integration.test.ts | Real D1 and Better Auth sign-up, login and delegated user and admin flows. |
docs/mcp-connections.md | The repository's own write-up of the design. |
Adding a tool safely
- Register it in
core.tsnext to the existingserver.registerToolcalls for the right audience. User tokens never see admin tools and the reverse, so keep the audience split. - Put the data access in
adapter.tsand go through the same checks the website uses:requireWorkspaceContextandauthorizeWorkspacefor workspace data, a freshliveActoradmin check plus theADMIN_APPguard for admin data. - Treat tool arguments such as a workspace or user ID as untrusted. The authorized workspace comes from the grant, not from the agent.
- Keep results bounded. Reuse the
limitschema (1 to 50). - A write tool needs a new scope, consent wording that says so, and tests in
core.test.tsandmcp.integration.test.tsbefore you rely on it. The shared tools and permissions page explains the scope model.
If a connection stops working after you sign out, sign in again on the same origin and reconnect from the settings page. See troubleshooting for the other common cases.