Skip to content

feat(mcp): per-user OAuth for remote MCP servers - #928

Closed
dennisUhrskov wants to merge 5 commits into
getnao:mainfrom
dennisUhrskov:feat/mcp-oauth-settings-ui
Closed

feat(mcp): per-user OAuth for remote MCP servers#928
dennisUhrskov wants to merge 5 commits into
getnao:mainfrom
dennisUhrskov:feat/mcp-oauth-settings-ui

Conversation

@dennisUhrskov

@dennisUhrskov dennisUhrskov commented Jun 18, 2026

Copy link
Copy Markdown

Issue: #929

Adds per-user OAuth so a deployed, multi-user nao can connect to OAuth-only remote MCP servers — concretely Mixpanel's hosted MCP (https://mcp-eu.mixpanel.com/mcp), but the design is generic. Today nao only supports static headers / stdio env for remote servers; for OAuth-only servers McpService hands off to mcporter's built-in flow, which opens the system browser and caches a single token under ~/.mcporter/. That works for local nao chat but breaks in a deployed pod (no browser, and one shared vault means no per-user identity — yet these servers scope permissions to the logged-in user).

For servers that opt in via a new oauth config block, nao bypasses mcporter and drives the @modelcontextprotocol/sdk StreamableHTTPClientTransport with a nao-owned, DB-backed OAuthClientProvider and a web callback. stdio and static-header HTTP servers are untouched.

What's included (commit by commit)

  1. Config schema — optional oauth block (clientId?, clientSecretEnv?, scopes?, dynamicRegistration?) in apps/shared/src/mcp.ts, mirrored in the CLI (cli/nao_core/config/mcp/) with a Mixpanel template. Fully backward-compatible.
  2. Token store (dual-DB)mcp_oauth_token table keyed by (userId, projectId, serverName) in both the Postgres and SQLite schemas, with 0049 migrations in both dirs, plus queries/mcp-oauth.queries.ts.
  3. OAuthClientProviderservices/mcp-oauth-provider.ts implementing the SDK interface against the token store, scoped to one (userId, projectId, server) session. Durable tokens + dynamically-registered client info in the DB; the transient PKCE verifier and CSRF state in memory. Supports dynamic client registration (RFC 7591) with a fallback to a configured clientId.
  4. Web connect + callback routesroutes/mcp-oauth.ts (/api/mcp-oauth/connect/:projectId/:serverName and /api/mcp-oauth/callback) + services/mcp-oauth.service.ts, backed by a durable mcp_oauth_flow table (0050 migrations) holding the PKCE verifier/state across the redirect boundary. Membership-gated; open-redirect guard on returnTo.
  5. Per-user executionMcpService diverts oauth servers off mcporter and opens a cached per-(user, server) SDK connection authenticated with the user's own tokens (services/mcp-user-connection.ts). Tool schemas fold into the shared per-project state (enable/disable keeps working); execution runs under the calling user's token. userId is threaded ToolContext → getTools → getMcpTools.
  6. Settings UI + docs — per-server Connect/Disconnect + connection status in Settings → MCP Servers (any member; tool enable/disable stays admin-only), oauthStatus/disconnectOAuth tRPC, search-index entry, and apps/backend/docs/mcp-oauth.md.

Design decisions for review

  • OSS, not EE-gated. This is an outbound connector (nao → an external tool's MCP), like routes/slack.ts / teams.ts / telegram.ts, not a nao-login identity provider. Kept modular so it can be wrapped in hasFeature(...) later if you disagree (a one-line guard).
  • Bypass mcporter rather than upstreaming an injected-provider feature request — mcporter 0.7.3 exposes no hook to inject a custom OAuthClientProvider. Open to upstreaming instead if you'd prefer.
  • Durable pending-flow table (mcp_oauth_flow) for the PKCE handshake rather than an in-memory map, so it survives restarts / multiple pods.
  • Per-user schema discovery. A server's tools appear after that user connects, rather than being pre-discovered from a shared service token.
  • Deferred: the in-conversation Connect card (surfacing UnauthorizedError inline in chat). The settings flow is the entry point; the card is a follow-up.

Testing

  • Unit tests: provider, connect/callback service, per-user connection manager, and McpService routing (proving stdio/static-header servers are unaffected) — 47 tests.
  • Live, against the real Mixpanel hosted MCP (behind a public HTTPS tunnel):
    • Dynamic client registration works with no configured clientId.
    • Mixpanel accepts the web callback URL; the SDK exchanged the code and persisted tokens.
    • With empty scopes, the full tool list is returned and tools appear in Settings → MCP Servers immediately after connecting.
    • Connect → Connected status → tools listed → Disconnect all work from the settings UI.

Most of the diff is generated Drizzle migration snapshots (0049/0050).

This PR was written using Claude Opus 4.8 (claude-opus-4-8).

PR 1: add optional `oauth` block (clientId, clientSecretEnv, scopes,
dynamicRegistration) to the shared mcp.json schema, fully backward-compatible,
and mirror it in the CLI via a Mixpanel OAuth template selectable from `nao init`.

PR 2: add dual-DB `mcp_oauth_token` table keyed by (userId, projectId,
serverName), mirroring the better-auth `account` columns, plus
queries/mcp-oauth.queries.ts and matching 0048 migrations for both
postgres and sqlite.
Implement McpOAuthProvider, an @modelcontextprotocol/sdk OAuthClientProvider
scoped to a (userId, projectId, serverName) session. Tokens and dynamically
registered client info persist via the mcp_oauth_token store; the PKCE verifier
and CSRF state stay in memory and are exposed for the web connect/callback
routes to carry across the redirect boundary. Supports dynamic client
registration with a fallback to a statically configured clientId/secret.

Add unit tests covering token mapping, client-info precedence, secret
resolution, and PKCE/state handling.
Add the per-user OAuth connection flow for remote MCP servers, driving the
@modelcontextprotocol/sdk auth orchestrator with the PR-3 provider:

- routes/mcp-oauth.ts: GET /api/mcp-oauth/connect/:projectId/:serverName starts
  the flow (returns the authorize URL or reports already-connected) and
  GET /api/mcp-oauth/callback validates the pending flow, exchanges the code,
  and persists tokens, redirecting back to the originating page.
- services/mcp-oauth.service.ts: orchestration, project mcp.json resolution,
  redirect-URI construction, and an open-redirect guard on returnTo.
- mcp_oauth_flow table (both dialects, 0050 migrations) + queries to hold the
  transient PKCE verifier and CSRF state durably across the redirect boundary.

Connect is authorized to any project member; tokens stay scoped to the calling
user's own credentials on the remote server. Add service unit tests covering
flow persistence, the open-redirect guard, and callback validation.
Make the McpService singleton user-aware for OAuth-protected servers while
leaving stdio and static-header servers on mcporter untouched:

- Servers declaring an oauth block are diverted off mcporter's auth:'oauth'
  flow (which needs a browser and breaks in a deployed pod) and handled via
  the SDK transport instead.
- McpUserConnections opens, caches, and tears down a per-(user, server) SDK
  client authenticated with the user's own tokens (PR-3 provider); tool
  execution runs under the calling user's credentials, refreshing on demand
  and dropping the connection on an auth failure.
- Tool schemas fold into the shared per-project state so enable/disable and
  the settings view keep working; servers a user has not authorized contribute
  no tools until they connect.
- Thread userId from ToolContext through defaultAgentTools -> getTools ->
  getMcpTools, warming the user's OAuth connections before building the run.

Add unit tests for the connection manager and McpService routing, proving
stdio/static-header servers are registered with mcporter and OAuth servers
are not.
Give project members a way to connect and disconnect their own account for
OAuth-protected MCP servers from Settings -> MCP Servers:

- mcp tRPC router: oauthStatus query (per-user connection state for each OAuth
  server) and disconnectOAuth mutation (deletes the user's tokens and tears
  down their connection). getState now also warms the calling user's OAuth
  connections so a connected server's tools show immediately in settings,
  rather than only after the first chat run. McpService exposes
  getOAuthServerNames and disconnectUserOAuthServer.
- display-mcp.tsx: per-OAuth-server Connect/Disconnect button and connection
  status, available to any member (tool enable/disable stays admin-only). The
  Connect button starts the OAuth flow and navigates to the authorize URL;
  the callback returns to this page, which refreshes status.
- settings-search-index: index the OAuth connection control.
- docs/mcp-oauth.md: setup and how the flow works.

Extend the McpService routing test to cover the new methods.

The in-conversation Connect card (surfacing UnauthorizedError inline in chat)
is intentionally deferred to a follow-up.
@github-actions

Copy link
Copy Markdown
Contributor

This PR was auto-closed. Only contributors approved with lgtm can open PRs. Open an issue first.

Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in CONTRIBUTING.md will not be reopened or receive a reply.

If a maintainer replies lgtmi, your future issues will stay open. If a maintainer replies lgtm, your future issues and PRs will stay open.

See CONTRIBUTING.md.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🧹 Preview Removed

The preview deployment for this PR has been cleaned up.

@Bl3f Bl3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current annoying part with the MCP is that it requires to go in the Settings in the MCP page to connect for the LLM to be aware of the MCP server. Which means a user who want to use Mixpanel will have first to go in the settings to auth in order for the MCP to be discoverable

I think it should be done a bit differently, if as an admin you have registered the MCP the LLM should be aware it exists but that i requires OAuth connection, when a user asks something related the auth should be inline in the conversation so it’s convenient for the user.

For this reason I think we cant merge as is. We will work on it asap to implement what I just said AND significantly improve how MCP behaves in the app.

@Bl3f

Bl3f commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@dennisUhrskov we did not forget you! I've opened a new PR that implements Oauth + improve how the MCP client works (cf. #1049). I will close this PR once the other has been merged!

@Bl3f

Bl3f commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

The other PR has been merged, it will land in 0.2.6 asap.

@Bl3f Bl3f closed this Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants