Adding an MCP Server with OAuth 2.1 to a NestJS SaaS
• 9 min read

Adding an MCP Server with OAuth 2.1 to a NestJS SaaS

How Dopamine lets users connect Claude, ChatGPT and Cursor to their account: the OAuth 2.1 flow MCP clients expect, stateless client registration, rotating refresh tokens, consent per workspace, and tools you add in one file.

by Jonathan Beurel

AI assistants are becoming the place where people ask questions about their work. If your SaaS holds that work (projects, invoices, meetings), the question is no longer whether Claude or ChatGPT will try to reach it, but how.

The Model Context Protocol (MCP) is the answer most clients agreed on. Claude, ChatGPT, Cursor and Claude Code all speak it. So I added an MCP server to the Dopamine starter kit, and to Dopamine Notetaker, where it lets your customers ask their AI assistant about their meetings. Goelo, the AI meeting assistant I run on the Notetaker engine, serves it in production.

The tools were the easy part. The login was not. This article walks through what MCP clients expect from your API, and the decisions that kept the implementation small.

What an MCP Client Expects

A remote MCP server is one HTTP endpoint, POST /mcp, that speaks JSON-RPC. What makes it interesting is how a client like claude.ai gets permission to call it. There is no API key field: the client discovers your OAuth server on its own, registers itself, and opens a login page in the browser.

sequenceDiagram
    participant Client as MCP client
    participant API as API (NestJS)
    participant Web as Web app (Next.js)
    Client->>API: POST /mcp without a token
    API-->>Client: 401 + WWW-Authenticate: resource_metadata
    Client->>API: GET the two .well-known documents
    Client->>API: POST /oauth/register
    Client->>Web: browser: login and consent
    Web-->>Client: redirect with a one-time code
    Client->>API: POST /oauth/token (code + PKCE verifier)
    Client->>API: POST /mcp with Bearer token

In OAuth terms, the MCP specification asks for the authorization code flow with PKCE, refresh tokens, dynamic client registration (RFC 7591), and two discovery documents: the protected resource metadata and the authorization server metadata. That is what the kit implements, and nothing more.

The 401 That Starts Everything

Everything starts with one header. When a client calls /mcp without a valid token, the answer must carry WWW-Authenticate with the URL of the resource metadata. That header is how Claude, Cursor or ChatGPT find your OAuth server and open the login page, so it goes on every 401, and never on a 200.

if (!grant) {
  const challenge = [
    `resource_metadata="${this.config.resourceMetadataUrl}"`,
    `scope="${SUPPORTED_SCOPES.join(' ')}"`,
    ...(accessToken ? ['error="invalid_token"'] : []),
  ];
  context
    .switchToHttp()
    .getResponse<Response>()
    .setHeader('WWW-Authenticate', `Bearer ${challenge.join(', ')}`);
  throw new UnauthorizedException(accessToken ? 'Invalid or expired access token.' : 'Missing access token.');
}

This guard also rejects the web app's own session JWTs: they are not bound to the MCP resource, so only tokens issued by the OAuth flow open /mcp.

Decision 1: The client_id Is the Client

Dynamic client registration means every user who adds your server in Claude registers a new client. The obvious design is a clients table that grows forever and needs a cleanup job.

I went the other way: the client_id is a JWT signed with a server secret, carrying the client name and its redirect URIs. Registration writes nothing to the database, and resolving a client is a signature check.

const clientId = await new jose.SignJWT({ client_name: clientName, redirect_uris: metadata.redirect_uris })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuer(this.config.apiUrl)
  .setIssuedAt(issuedAt)
  .setExpirationTime(CLIENT_ID_TTL)
  .sign(this.config.signingKey);

The trade-off: a registered client cannot be deleted before it expires. That is fine, because a client alone grants nothing. Access lives in grant rows, one per connected app, and users revoke those from a Connected apps page.

Redirect URIs are validated at registration: https anywhere, plain http only on loopback addresses (desktop clients listen on a local port), and app schemes for native clients. javascript:, data: and file: are refused. For loopback redirects, only the port may differ at login, because native clients pick a free port each time (RFC 8252).

Decision 2: Opaque Tokens, Rotated, Stored Hashed

Access tokens are opaque strings, not JWTs. The database stores their SHA-256 on the grant row: an access token lives one hour, a refresh token 30 days, sliding. Revoking a connection takes effect on the next request, without a deny list.

Each refresh replaces both tokens and keeps the hash of the previous refresh token. If that previous token ever comes back, someone replayed it, so the whole grant is revoked:

if (!grant) {
  const replayedGrant = await this.grantRepository.findByPreviousRefreshTokenHash(refreshTokenHash);
  if (replayedGrant) {
    await this.grantRepository.revoke({ id: replayedGrant.id });
    throw new OAuthException('invalid_grant', 'Refresh token reuse detected: the connection was revoked.');
  }
  throw new OAuthException('invalid_grant', 'Unknown refresh token.');
}

Errors use the OAuth format, { error, error_description }, instead of the Nest default, because clients branch on the error field.

Dopamine is multi-tenant: a user belongs to several workspaces. A token that reads all of them by default would be the wrong default, so the consent screen works like GitHub's: the user ticks the workspaces the app may read, and at least one is required.

The access token carries the ticked workspaces, and the guard narrows them to the user's current memberships on every request. Leaving a workspace cuts the access immediately, without touching the grant.

The consent screen lives on the web app, not on the API, because that is where the Auth.js session is. The API validates the authorization request, then redirects the browser to APP_URL/oauth/authorize. No state is stored between the two: the browser carries the original query string, and the API validates it again when the user submits the form.

Decision 4: A Stateless MCP Server

On the MCP side, the official SDK does the protocol work. createMcpHandler builds a fresh server for each HTTP request, which keeps the endpoint stateless and lets each server register only the tools the token's scopes allow:

private createServer(context: McpToolContext): McpServer {
  const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
 
  for (const tool of this.tools.filter((tool) => context.scopes.includes(tool.scope))) {
    server.registerTool(
      tool.name,
      {
        title: tool.title,
        description: tool.description,
        inputSchema: tool.inputSchema,
        annotations: { readOnlyHint: tool.scope === 'read' },
      },
      (input) => this.callTool(tool, input, context),
    );
  }
 
  return server;
}

The same route serves today's clients, which expect a one-event SSE stream, and clients on the newer protocol version, which get plain JSON. The SDK picks the format.

Decision 5: Tools Are Nest Providers

Tools are grouped in toolsets, plain @Injectable() classes listed in one array. A fork adds its tools without touching the core: Dopamine Notetaker adds its five meeting tools this way (list_meetings, get_meeting_summary, find_past_meetings, find_known_people, get_meeting_transcript) next to the four core ones.

@Injectable()
export class ProjectToolset implements McpToolset {
  constructor(private readonly projectService: ProjectService) {}
 
  readonly tools = [
    defineMcpTool({
      name: 'list_projects',
      title: 'List projects',
      description: 'Lists the projects of a workspace, most recent first.',
      inputSchema: z.object({ workspaceId: z.uuid().describe('ID returned by list_workspaces.') }),
      scope: 'read',
      handler: ({ workspaceId }, context) => {
        if (!context.workspaceIds.includes(workspaceId)) {
          throw new McpToolError('This workspace is not available to this connection.');
        }
        return this.projectService.findByWorkspace(workspaceId);
      },
    }),
  ];
}

The rule that matters: the context comes from the token, never from the model. The user and the workspaces are set by the guard. The model only chooses the input, and every ID it sends is checked against the context. In the Notetaker, "not found" and "not allowed" even return the same answer, so a model cannot probe what exists in other workspaces.

Decision 6: Read-Only by Default

The kit ships no tool that changes data. A write tool can be triggered by a prompt injection hidden in the data the model reads: a meeting transcript, a project description, an email. Adding one is a deliberate step: a write scope with its own consent wording, and a destructiveHint annotation so clients ask the user before running it.

Each connected app is also limited to 120 requests per minute. That is plenty for an agent, and a cap for one stuck in a loop.

Two Public URLs

One deployment detail surprised me. claude.ai and ChatGPT call your MCP server from their own infrastructure, so the API needs a public URL, separate from the web app: API_URL, the same origin Stripe calls for its webhooks. Every URL advertised to MCP clients lives there. CORS stays limited to the web app, except on the routes MCP clients call from any origin.

For local development, Cursor and the MCP Inspector work on localhost. claude.ai needs a tunnel (ngrok http 8080) and API_URL set to the tunnel URL.

Testing the Whole Flow

Unit tests cover PKCE, redirect URI rules, client IDs and the guard. But OAuth bugs hide between the steps, so an end-to-end suite runs the whole flow against a real test database. It checks, among other things, that:

  • an unauthenticated call gets the challenge that starts the flow;
  • the consent screen only lists the user's own workspaces, and refuses one they are not a member of;
  • an authorization code works once, and only with the right PKCE verifier;
  • tools only see the granted workspaces, and lose a workspace the user left;
  • a replayed refresh token revokes the grant;
  • a web session JWT is rejected on /mcp.

What I Left Out

  • Client ID Metadata Documents: every current client supports dynamic registration.
  • The revocation endpoint (RFC 7009): users revoke from the Connected apps page.
  • A purge job for authorization codes: the rows are tiny and single-use.

Each of these can be added later without changing the rest.

Try It

The MCP server and its OAuth login ship with the Dopamine starter kit and with Dopamine Notetaker. The documentation covers how to connect each client and how to add your own tools.