Agent connections
The User MCP and Admin MCP servers in the Next.js + Neon starter: endpoints on the web and admin origins, tools, OAuth behaviour and how to add a tool.
This kit ships two remote MCP servers, one in each Next.js app. They use your existing Better Auth login, so an agent never gets a password or a database credential. For the general flow, read user and admin MCPs and connect an agent.
Two connections on two origins
The User MCP reads account context and projects in one selected workspace and is served by the web app. The Admin MCP lists users and workspaces across the app and is served by the admin app. Both are read-only in v5.
| Connection | Served by | Settings page | Live demo server |
|---|---|---|---|
| User | apps/web | /mcp/connections/user on the web origin | https://nextjs-neon.usenextbase.com/mcp/user |
| Admin | apps/admin | /mcp/connections/admin on the admin origin | https://nextjs-neon-admin.usenextbase.com/mcp/admin |
The settings pages are linked as Agent connections in the web app sidebar and Admin agent connections in the admin app sidebar. Each page shows the server address to copy, lists that account's connections and offers Disconnect. The admin page only opens for administrators signed in on the admin host.
Each app answers only its own audience. The web routes call handleMcp(request), which defaults to the user audience, and the admin routes call handleMcp(request, "admin"); a request for the other audience returns 404. So /mcp/admin and /mcp/connections/admin don't exist on the web origin, and /mcp/user doesn't exist on the admin origin.
Your own deployment has its own addresses. Each server's origin is the NEXT_PUBLIC_APP_URL of the app serving it: the User MCP is <web NEXT_PUBLIC_APP_URL>/mcp/user and the Admin MCP is <admin NEXT_PUBLIC_APP_URL>/mcp/admin. Locally that is http://localhost:3000/mcp/user and http://localhost:3001/mcp/admin.
Connecting
Copy the server address into your agent's remote MCP settings and choose OAuth. The client must support Streamable HTTP, public dynamic client registration, the authorization code flow and PKCE S256.
The agent is sent to /oauth/authorize on the same origin, which redirects to the consent page at /mcp/authorize?request_id=.... If you aren't signed in, you go to /login?next=... on that origin and return to the same request; for the admin server that is the admin sign-in page with email and password. The consent page shows the client name (marked as unverified, because the client supplies it), the callback address, the server and the permission. For a user connection you pick one workspace; if you have none, create or join one first. Choose Allow read access or Cancel.
Ask the agent to list projects in that workspace to confirm it works. An empty list is valid until you add a project.
Tools and permissions
Scopes are exactly user:read for the user server and admin:read for the admin server. Every tool is registered with readOnlyHint: true. List tools accept a limit between 1 and 50, default 20.
| Server | Tool | Scope | What it returns |
|---|---|---|---|
| User | account_context | user:read | The signed-in account and the approved workspace |
| User | projects_list | user:read | Projects in the approved workspace: id, name, slug, description, status, updated_at |
| Admin | admin_users_list | admin:read | Users across the app: id, name, email, role |
| Admin | admin_workspaces_list | admin:read | Workspaces across the app: id, name, slug |
Permission is re-checked on every call. liveActor in apps/web/src/mcp/adapter.ts reloads the session and requires that it hasn't expired, isn't an impersonation session, and that the user is verified and not banned. projects_list filters by the granted workspace and re-checks membership in SQL. Admin tools re-check users.role = 'admin' before every query. A user token is rejected at the admin endpoint and the other way round, and because the two servers live on different origins their tokens are also bound to different resources.
Being an administrator doesn't widen a user connection to other workspaces. Connect to the admin server on the admin origin separately when you need application-wide data.
OAuth details specific to this kit
- Issuer:
<NEXT_PUBLIC_APP_URL>/oauthof the app you connect to, with metadata at/.well-known/oauth-authorization-server/oauthon that origin. The web app advertises onlyuser:readand the admin app onlyadmin:readinscopes_supported. - Resource metadata:
/.well-known/oauth-protected-resource/mcp/useron the web origin and/.well-known/oauth-protected-resource/mcp/adminon the admin origin. - Endpoints on each origin:
/oauth/register,/oauth/authorize,/oauth/token,/oauth/revoke. - Public clients only (
token_endpoint_auth_method=none). Confidential clients and client ID metadata documents aren't supported. - Callback URLs must be HTTPS, except HTTP loopback callbacks on
localhost,127.0.0.1or::1. - Lifetimes: pending consent 10 minutes, authorization codes 5 minutes, access tokens 15 minutes, refresh up to 30 days but never longer than the sign-in session that approved the connection.
- Refresh tokens rotate. Reusing an old one revokes the whole connection.
- Up to 20 active connections per account.
- Browser requests are accepted from the serving app's own origin,
https://claude.aiandhttps://chatgpt.com. Desktop and server clients without anOriginheader are accepted too. - Opening
/mcp/useron the web origin or/mcp/adminon the admin origin without a token returns a 401 with aWWW-Authenticatechallenge pointing at the resource metadata. That is expected.
Signing out, disconnecting, losing workspace membership, being banned or losing the admin role ends delegated access. Reconnect from a new sign-in when you need to.
Where the code lives
The implementation is in the web app and shared with the admin app through its @/* alias, which falls back to apps/web/src.
| Path | Role |
|---|---|
apps/web/src/mcp/core.ts | Protocol, OAuth, consent and connection pages, audience isolation, the Adapter interface and the tool registry |
apps/web/src/mcp/adapter.ts | handleMcp(request, audience) and the Postgres adapter: parameterized SQL on the shared pool, session revalidation, workspace lookups |
apps/web/src/mcp/pg-store.ts | Credential storage in the mcp_records table with compare-and-set updates |
apps/web/src/app/mcp/[...path]/route.ts, apps/web/src/app/oauth/[...path]/route.ts and apps/web/src/app/.well-known/ | Mount the handler for the user audience on the web origin |
apps/admin/src/app/mcp/[...path]/route.ts, apps/admin/src/app/oauth/[...path]/route.ts and apps/admin/src/app/.well-known/ | Mount the same handler with the admin audience on the admin origin |
apps/web/src/components/sidebar-platform-nav.tsx and apps/admin/src/components/sidebar-admin-panel-nav.tsx | The sidebar links to the two connection pages |
apps/web/src/mcp/core.test.ts | Vitest suite with an in-memory store covering consent, CSRF, PKCE, replay, expiry, membership loss and demotion |
apps/web/src/mcp/pg-store.integration.test.ts | Store test against a loopback Postgres given by MCP_TEST_PG_URL |
apps/database/drizzle-postgres/0002_*.sql and 0003_*.sql | The mcp_records table, its indexes and the guarded grant to an app_runtime role |
docs/mcp-connections.md | Design notes in the repository |
Secrets in mcp_records are stored as SHA-256 hashes. Expired rows are cleaned up in bounded batches as traffic arrives, so there is no always-on poller.
Adding a tool safely
The repository ships an mcp-tool skill in .agents/skills/mcp-tool/SKILL.md that walks a coding agent through this. The short version:
- Add a method to the
Adapterinterface incore.ts. User tools take the actor and the granted workspace; admin tools take the actor only. - Implement it in
adapter.ts. User queries filter byworkspace.idand re-check membership in SQL, likeprojectsdoes. Admin queries start by confirmingliveActor(actor)?.admin. Select explicit columns, never secrets ormcp_records, and bound results with alimit. - Register it with
server.registerToolinside the right audience branch only, with a zodinputSchemaand the sharedannotations. UselimitSchemafor list tools. Name user tools<noun>_<verb>and prefix admin tools withadmin_. Admin tools are served by the admin app automatically, because its routes pass theadminaudience to the same handler. - Extend
core.test.ts: the fake adapter, thetools/listexpectations, atools/calltest, and negative tests for the wrong audience, invalid input, membership loss and admin demotion. - Add the tool to the table in
docs/mcp-connections.md.
Write tools are a design change, not a new method. They need a new scope, consent copy, readOnlyHint: false, the existing policy asserts from apps/web/src/lib/domain/, and audit and outbox rows via domainEventValues. Keep the authenticated identity on the server: a workspaceId argument from the client is never proof of access.
Verify with pnpm --filter web exec vitest run src/mcp/core.test.ts, then pnpm lint && pnpm typecheck && pnpm test. For a manual check, run pnpm local:start and connect an OAuth MCP client to http://localhost:3000/mcp/user; for the admin server, also run pnpm --filter admin dev and connect to http://localhost:3001/mcp/admin.
Authentication
How Better Auth is configured in the Next.js + Neon starter, how sessions, the separate admin app and impersonation work, and what protects your data.
Project structure
The Next.js + Neon monorepo at a glance and where to make changes for routes, the admin app, data, schema, auth, emails, jobs, MCP and tests.