Agent connections
The User MCP and Admin MCP in the TanStack + Supabase starter: endpoints, tools, OAuth behaviour and how to extend them.
The kit serves two remote MCP servers, one per deployable app. The web app's Express process serves the User MCP next to the website; the admin app's Express process serves the Admin MCP next to the administration UI. Users connect agents to one workspace through the User MCP; application administrators connect through the Admin MCP. Both are read-only in v5. For the shared concepts and the client-side flow, see user and admin MCPs and connect an agent.
Where users manage connections
| Connection | App and settings page | Live demo server |
|---|---|---|
| User | Web app, /mcp/connections/user | https://tanstack-supabase.usenextbase.com/mcp/user |
| Admin | Admin app, /mcp/connections/admin | https://tanstack-supabase-admin.usenextbase.com/mcp/admin |
The repository's docs/admin-app.md maps each kit's admin deployment to <subdomain>-admin.usenextbase.com, which gives the admin address above for the demo. The product navigation has an Agent connections entry and the account settings page links to it. Administrators see Admin agent connections in the admin app's navigation. Each page shows the server address to copy, the list of connected agents with their workspace and scope, and a Disconnect button per connection. The UI limits active connections to 20 per account.
Your own deployments use each app's APP_URL as the origin, so the servers are ${APP_URL of web}/mcp/user and ${APP_URL of admin}/mcp/admin. The settings pages print the exact address. Each app advertises and authorizes only its own audience: apps/web/src/mcp/adapter.ts sets audiences: ["user"] and apps/admin/src/mcp/adapter.ts sets audiences: ["admin"], so a request for /mcp/admin on the web origin (or /mcp/user on the admin origin) is rejected with invalid_target.
Tools and permissions
| Server | Tool | Scope | Input | What it reads |
|---|---|---|---|---|
| User | account_context | user:read | none | The signed-in user's id and email plus the approved workspace. |
| User | projects_list | user:read | limit (1 to 50, default 20) | Projects in the approved workspace only: id, name, slug, status, timestamps. |
| Admin | admin_users_list | admin:read | limit (1 to 50, default 20) | Newest user_profiles rows: id, full name, created date. |
| Admin | admin_workspaces_list | admin:read | limit (1 to 50, default 20) | Newest workspaces: id, name, slug, created date. |
Every tool is registered with readOnlyHint: true and destructiveHint: false. There are no write tools, and the scopes do not grant writes. A user token never lists or calls admin tools, and an admin token is rejected at /mcp/user.
Each tool call re-authenticates the bearer token and reloads the live actor through public.mcp_live_actor, which requires an existing auth.sessions row, a confirmed email, no active ban and a session that has not expired. projects_list runs public.mcp_projects, which checks membership and the view_projects permission in the same SQL statement as the read. Admin tools additionally require a current admin row in public.user_roles.
Consent and OAuth behaviour
The agent needs to support Streamable HTTP, OAuth with public dynamic client registration and PKCE S256. The steps below apply to each app on its own origin.
- The client discovers the server through
/.well-known/oauth-protected-resource/mcp/user(web) or/.well-known/oauth-protected-resource/mcp/admin(admin). The authorization server issuer is${APP_URL}/oauthwith metadata at/.well-known/oauth-authorization-server/oauth. - The client registers at
/oauth/registerand starts authorization at/oauth/authorize, which redirects to the consent page/mcp/authorize?request_id=.... - If you are not signed in, the app sends you to
/login?next=/mcp/authorize?...and returns you to the same request afterwards. - The consent page shows the client's self-reported name (marked unverified), the callback address, the server, the scope with "Read access only" and your email. For a user connection you select one workspace; only workspaces where you are owner, admin or have
view_projectsare offered. Approve with Allow read access or Cancel. - The client exchanges the code at
/oauth/token. Revocation is/oauth/revoke.
Lifetimes: pending consent 10 minutes, authorization codes 5 minutes, access tokens 15 minutes, refresh for at most 30 days and never beyond the original sign-in session. Refresh tokens rotate; reuse revokes the whole connection family. Secrets are stored only as SHA-256 hashes. Callback URLs must be HTTPS, except HTTP loopback addresses. Browser clients are accepted from the app's APP_URL, https://claude.ai and https://chatgpt.com; desktop and server clients without an Origin header are accepted too. Confidential clients and client ID metadata documents are not supported.
Access ends when you disconnect, sign out of the session that approved the connection, lose workspace access or lose the administrator role. If an agent starts getting 401, sign in again and reconnect.
Where the code lives
The MCP module is the same code in both apps; only adapter.ts differs (the audiences setting). Paths below are for apps/web; apps/admin/src/mcp/ has the same files minus the tests.
| Path | Purpose |
|---|---|
apps/web/src/mcp/core.ts | OAuth server, consent and connection pages, the Adapter interface and the per-audience tool registry (server.registerTool). |
apps/web/src/mcp/supabase-adapter.ts | Supabase implementation of Adapter using the service-role client and the mcp_live_actor and mcp_projects functions. |
apps/web/src/mcp/supabase-store.ts | Credential storage in public.mcp_records with compare-and-set updates. |
apps/web/src/mcp/adapter.ts | Wires env and request services into handleMcp and restricts the audience (["user"] here, ["admin"] in the admin app). |
apps/web/src/mcp/express.ts | Mounts the handler in server/index.ts for /mcp/*, /oauth/* and /.well-known/oauth-* before React Router. |
apps/web/src/mcp/core.test.ts | Protocol, audience, CSRF, PKCE, replay, expiry, membership and revocation tests with an in-memory store. |
apps/web/src/mcp/supabase-store.integration.test.ts | Storage test against a local Supabase project; skipped without the MCP_TEST_SUPABASE_* variables. |
apps/database/supabase/migrations/20261014114357_user_admin_mcp.sql | Creates mcp_records (service-role only, RLS enabled, browser grants revoked) and the two service-only functions. |
docs/mcp-connections.md | The repository's own contract for both servers. docs/admin-app.md records that the admin MCP moved to the admin origin. |
.agents/skills/mcp-tool/SKILL.md | Step-by-step skill for adding a tool, used by the AI coding tools configured in the repo. |
Do not add React Router routes under /mcp/, /oauth/ or /.well-known/oauth-* in either app; Express handles them first.
Adding a tool safely
Follow the mcp-tool skill in the repository. In short:
- Extend the
Adapterinterface incore.tsand register the tool inside the matching audience branch (audience === "user"or theelsebranch). Reuse the sharedannotationsandlimitSchema; validate any other input with Zod and bound strings with.max(...). - Inside the handler always call
await currentActor()and, for user tools, passauth.grant.workspace. Never accept a workspace id from tool input and never reuse an actor captured earlier. - Implement the data access in
supabase-adapter.ts. User tools should call a new service-only SQL function modelled onpublic.mcp_projectsthat checksmcp_live_actorand membership in the same statement. Admin tools re-check(await liveActor(actor))?.adminbefore a bounded select of explicit columns. Create the function in a new migration; never edit the shipped MCP migration. Then runpnpm gen-types-local. - Mirror the change into the other app's
src/mcp/so the two copies stay identical (user tools only matter in the web app and admin tools only in the admin app, but the sharedcore.tsshould not diverge). - Add tests in
apps/web/src/mcp/core.test.ts: update the exact tool-name lists, add a call test, a bounds test and a cross-audience test. Add a pgTAP test assertinganonandauthenticatedcannot execute the new function. - Update the tool table in
docs/mcp-connections.md.
pnpm --filter web exec vitest run src/mcp/core.test.ts
pnpm typecheck && pnpm lint
pnpm test-dbAdding a write tool needs a new scope and new consent copy first. The consent screen currently says "Read access only", and the repository's AGENTS.md lists adding write capability without that design as something to never do.