feat(mcp): per-user OAuth for remote MCP servers - #928
Conversation
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.
|
This PR was auto-closed. Only contributors approved with 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 See CONTRIBUTING.md. |
🧹 Preview RemovedThe preview deployment for this PR has been cleaned up. |
Bl3f
left a comment
There was a problem hiding this comment.
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.
|
@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! |
|
The other PR has been merged, it will land in 0.2.6 asap. |
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 staticheaders/ stdioenvfor remote servers; for OAuth-only serversMcpServicehands off to mcporter's built-in flow, which opens the system browser and caches a single token under~/.mcporter/. That works for localnao chatbut 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
oauthconfig block, nao bypasses mcporter and drives the@modelcontextprotocol/sdkStreamableHTTPClientTransportwith a nao-owned, DB-backedOAuthClientProviderand a web callback. stdio and static-header HTTP servers are untouched.What's included (commit by commit)
oauthblock (clientId?,clientSecretEnv?,scopes?,dynamicRegistration?) inapps/shared/src/mcp.ts, mirrored in the CLI (cli/nao_core/config/mcp/) with a Mixpanel template. Fully backward-compatible.mcp_oauth_tokentable keyed by(userId, projectId, serverName)in both the Postgres and SQLite schemas, with0049migrations in both dirs, plusqueries/mcp-oauth.queries.ts.OAuthClientProvider—services/mcp-oauth-provider.tsimplementing 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 configuredclientId.routes/mcp-oauth.ts(/api/mcp-oauth/connect/:projectId/:serverNameand/api/mcp-oauth/callback) +services/mcp-oauth.service.ts, backed by a durablemcp_oauth_flowtable (0050migrations) holding the PKCE verifier/state across the redirect boundary. Membership-gated; open-redirect guard onreturnTo.McpServicedivertsoauthservers 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.userIdis threadedToolContext → getTools → getMcpTools.oauthStatus/disconnectOAuthtRPC, search-index entry, andapps/backend/docs/mcp-oauth.md.Design decisions for review
routes/slack.ts/teams.ts/telegram.ts, not a nao-login identity provider. Kept modular so it can be wrapped inhasFeature(...)later if you disagree (a one-line guard).OAuthClientProvider. Open to upstreaming instead if you'd prefer.mcp_oauth_flow) for the PKCE handshake rather than an in-memory map, so it survives restarts / multiple pods.UnauthorizedErrorinline in chat). The settings flow is the entry point; the card is a follow-up.Testing
McpServicerouting (proving stdio/static-header servers are unaffected) — 47 tests.clientId.scopes, the full tool list is returned and tools appear in Settings → MCP Servers immediately after connecting.Most of the diff is generated Drizzle migration snapshots (
0049/0050).This PR was written using Claude Opus 4.8 (claude-opus-4-8).