Agent connections
User MCP and Admin MCP in the TanStack + Neon starter: settings pages, endpoints on the web and admin origins, tools, OAuth behaviour and where the code lives.
The kit ships two remote MCP servers. The User MCP runs on the web origin and the Admin MCP runs on the admin origin, because the admin application deploys separately. Both use OAuth with your existing Better Auth account, and both are read-only in v5. The shared pages cover the general flow: User and admin MCPs, connect an agent and tools and permissions.
Where users manage connections
| Connection | Settings page | Who can open it |
|---|---|---|
| User | /mcp/connections/user on the web app | Any signed-in, verified account |
| Admin | /mcp/connections/admin on the admin app | Accounts with the global admin role |
The web app shell lists Agent connections first in its navigation and the admin app shell lists Admin agent connections. These pages are rendered by Express, not React Router, so links to them use reloadDocument. Each page shows the server address to copy, lists the account's connections for that server with the client name, workspace (or "Application administration"), scope, connection date and expiry, and offers Disconnect. The UI caps active connections at 20 per account.
Each server answers only its own audience. Asking the web app for /mcp/admin or /mcp/connections/admin returns 404, and the admin app does the same for the user paths. The small cross-link at the bottom of each connections page points at the other audience's path on the same origin, so on a split deployment follow the table above instead.
Endpoints
| Server | Live demo | Your deployment |
|---|---|---|
| User MCP | https://tanstack-neon.usenextbase.com/mcp/user | {web APP_BASE_URL}/mcp/user |
| Admin MCP | https://tanstack-neon-admin.usenextbase.com/mcp/admin | {admin APP_BASE_URL}/mcp/admin |
Each app uses its own APP_BASE_URL as the MCP origin. Opening either URL in a browser without a token returns 401 with a WWW-Authenticate challenge pointing at the resource metadata; that is expected.
OAuth discovery and endpoints, each on the origin that serves the MCP server:
- Protected resource metadata:
/.well-known/oauth-protected-resource/mcp/user(web) and/.well-known/oauth-protected-resource/mcp/admin(admin) - Authorization server metadata:
/.well-known/oauth-authorization-server/oauth(issuer{APP_BASE_URL}/oauth). Each app advertises only its own scope inscopes_supported /oauth/register,/oauth/authorize,/oauth/token,/oauth/revoke- Consent page:
/mcp/authorize
Tools
| 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, newest first. limit 1 to 50, default 20 |
| Admin | admin_users_list | admin:read | Id, name, email and role of application users, newest first. limit 1 to 50, default 20 |
| Admin | admin_workspaces_list | admin:read | Id, name and slug of all workspaces, newest first. limit 1 to 50, default 20 |
All four are annotated readOnlyHint: true. There is no projects_create in this kit; the write tool exists only in the Convex kits. A user token never advertises admin tools and doesn't work at the admin endpoint, and an admin connecting to the User MCP gets only that one workspace's tools.
Each tool call reloads the grant and re-checks the native session in apps/web/src/mcp/adapter.ts: the session row must still exist and not be expired, the user must be verified and not banned, impersonated sessions are rejected, and admin tools re-check role = 'admin' on every request. Losing workspace membership or the admin role ends access even if the token hasn't expired.
OAuth and consent behaviour
The protocol is Streamable HTTP with stateless JSON responses (official TypeScript MCP SDK 1.32.1). The kit supports public clients with dynamic client registration, authorization code with mandatory PKCE S256, exact callback matching and exact resource binding. Confidential clients and client ID metadata documents are not supported. Callback URLs must be HTTPS, except HTTP loopback on localhost, 127.0.0.1 or ::1. Request bodies are capped at 32 KB.
When an agent starts a connection, the kit redirects to /mcp/authorize?request_id=.... If you aren't signed in, you go to /login?returnTo=/mcp/authorize?... and come back to the same request after a full document load. The consent page shows the client name (marked unverified, because the client supplies it), the callback address, the server, the scope with "Read access only" and the account you are signed in as. For a user connection you pick one workspace from the ones you belong to; if you have none, the page tells you to create or join a workspace first. The only choices are Allow read access and Cancel.
Lifetimes: pending consent 10 minutes, authorization codes 5 minutes, access tokens 15 minutes, refresh up to 30 days and never longer than the native session that approved it. Refresh tokens rotate, and reusing an old one revokes the whole connection. Secrets are stored as SHA-256 hashes only.
Browser origins allowed for MCP requests are the app's own APP_BASE_URL, https://claude.ai and https://chatgpt.com. Desktop and server clients without an Origin header are accepted.
Where the code lives
| Path | Role |
|---|---|
apps/web/src/mcp/core.ts | Provider-independent protocol: discovery, registration, consent HTML, tokens, revocation, tool registry, rate limits |
apps/web/src/mcp/adapter.ts | This kit's adapter: handleMcp(request, audience) reads the live session from the Better Auth tables, lists workspaces and projects through withActorTransaction, checks role = 'admin' for admin tools |
apps/web/src/mcp/pg-store.ts | Storage in public.mcp_records using compare-and-set on a revision column |
apps/web/src/mcp/express.ts | Bridges Express to the fetch-style handler for /mcp/*, /oauth/* and /.well-known/oauth-* |
apps/web/server.ts | Mounts expressMcp(handleMcp, env.APP_BASE_URL) with the default user audience |
apps/admin/server.ts | Mounts the same bridge with handleMcp(request, "admin"), so only the admin audience exists on that origin |
apps/database/migrations/0003_user_admin_mcp.sql | Creates mcp_records with RLS enabled, revokes it from PUBLIC, app_runtime and worker_runtime, grants it to auth_runtime only |
apps/web/src/mcp/core.test.ts | Unit tests for audience isolation, PKCE, callbacks, CSRF, replay, expiry, demotion, revocation and the split user/admin surfaces |
apps/web/src/mcp/pg-store.integration.test.ts | Storage test. Runs only when MCP_TEST_PG_URL points at a loopback database |
MCP credential storage runs on the auth_runtime connection, so application roles can't read tokens. Tool data for users still goes through withActorTransaction and RLS, the same as the website.
Adding a tool safely
The repository has an mcp-tool skill in .agents/skills/ that walks an AI coding tool through these steps.
- Add the data access to
apps/web/src/mcp/adapter.ts, insidewithActorTransactionfor anything tenant-scoped. Call the existing services or the same SQL shape they use rather than new unchecked queries. - Register the tool in
apps/web/src/mcp/core.tsunder the right audience (useroradmin) with a bounded input schema and honest annotations. KeepreadOnlyHint: trueunless you are deliberately adding a write and a new scope. - Resolve the actor with
currentActor()inside the handler. Never trust aworkspaceIdoruserIdargument as proof of permission; the grant already carries the approved workspace. - Add both the allowed and the denied case to
core.test.ts, and an integration test if the query touches RLS. - Run
pnpm verify,pnpm test:integrationandpnpm db:checkbefore deploying. Admin tools ship with the admin app, user tools with the web app; deploy whichever you changed.
If you add a write tool, also add a distinct scope, show it on the consent page, and keep the current read-only scopes unable to perform it. See the shared tools and permissions page for the cross-kit rules.
Authentication
How Better Auth, global and workspace roles, the separate admin app and PostgreSQL row-level security work in the TanStack + Neon starter.
Project structure
The TanStack + Neon monorepo layout and where to change routes, services, schema, auth, the admin app, emails, jobs, MCP and tests.