MCP Server & OAuth

Let your users connect Claude, ChatGPT and Cursor to their account with an MCP server and an OAuth 2.1 login, then expose your own features as tools

Dopamine ships a Model Context Protocol server and the OAuth 2.1 login it needs. Your users connect Claude, ChatGPT, Cursor or any MCP client to their account the way "Sign in with GitHub" works: the client opens a login page, the user picks the workspaces it may read, and the client receives tokens it refreshes on its own. Nobody copies an API key.

Two modules do the work, both in apps/api:

  • src/oauth: the OAuth 2.1 server. It implements what the MCP specification requires, and nothing more: authorization code with PKCE, refresh tokens, dynamic client registration and the two discovery documents.
  • src/mcp: the MCP server on POST /mcp, with read-only tools bound to the caller's user and granted workspaces.

The web app adds the consent screen (/oauth/authorize) and a Connected apps page in the account settings, where users see each connected app with its workspaces and last use, and revoke it.

Connect a client

The server URL is ${API_URL}/mcp (http://localhost:8080/mcp locally).

ClientHow
claude.aiSettings, Connectors, Add custom connector, paste the URL. Needs a public URL.
Claude Codeclaude mcp add --transport http dopamine http://localhost:8080/mcp
Cursor~/.cursor/mcp.json: { "mcpServers": { "dopamine": { "url": "http://localhost:8080/mcp" } } }
ChatGPTSettings, Connectors, Create, paste the URL. Needs a public URL.
MCP Inspectornpx @modelcontextprotocol/inspector, then Streamable HTTP and the URL

The client opens the login page by itself on first use. claude.ai and ChatGPT call the server from their own infrastructure: in local development, expose port 8080 with a tunnel (ngrok http 8080) and set API_URL to the tunnel URL.

How the login works

Machines call the API directly on API_URL. The only human step, login and consent, happens on the web app, where the Auth.js session lives.

sequenceDiagram
    participant Client as MCP client
    participant API as API (NestJS)
    participant Web as Web app (Next.js)
    participant User
    Client->>API: POST /mcp without a token
    API-->>Client: 401 + resource metadata URL
    Client->>API: GET /.well-known/oauth-authorization-server
    Client->>API: POST /oauth/register
    API-->>Client: client_id
    Client->>API: GET /oauth/authorize (PKCE challenge), in the browser
    API-->>Web: redirect to APP_URL/oauth/authorize
    User->>Web: logs in, ticks workspaces, clicks Authorize
    Web-->>Client: redirect with a one-time code
    Client->>API: POST /oauth/token (code + PKCE verifier)
    API-->>Client: access token + refresh token
    Client->>API: POST /mcp with Bearer token

No state is kept between the redirect and the consent: the browser carries the original query string, and the API validates it again each time.

Design choices

  • No client table. A client_id is a JWT signed with OAUTH_SIGNING_SECRET that carries the client name and redirect URIs. Registration writes nothing, and resolving a client is a signature check. A client alone grants nothing: access lives in OAuthGrant rows.
  • Opaque tokens, stored hashed. One OAuthGrant row per connected app holds the SHA-256 of its access token (1 hour) and refresh token (30 days, sliding). Revoking from Connected apps takes effect on the next request.
  • Refresh token rotation. Each refresh replaces both tokens. Presenting a previous refresh token again means it was stolen or replayed: the whole grant is revoked and the user logs in again.
  • Workspaces are checked on every request. The access token carries the workspaces ticked at consent, narrowed to the user's current memberships. Leaving a workspace cuts the access without touching the grant.
  • Errors use the OAuth format ({ error, error_description }), because clients branch on error.

Tools shipped with the kit

All read-only (scope read), in src/mcp/toolsets/core.toolset.ts:

ToolReturns
whoamiThe user, the workspaces this connection can read, scopes
list_workspacesGranted workspaces with member count and subscription plan
list_workspace_membersActive members of a granted workspace
get_credit_balanceCredit balance and recent transactions of a granted workspace

Dopamine Notetaker adds five meeting tools on the same endpoint: list_meetings, get_meeting_summary, find_past_meetings, find_known_people and get_meeting_transcript.

The server is stateless: the official SDK builds a fresh server for each request, with only the tools the token's scopes allow.

Add your own tools

  1. Create an @Injectable() class implementing McpToolset, next to core.toolset.ts:
@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);
      },
    }),
  ];
}
  1. Add it to MCP_TOOLSETS in mcp-toolsets.ts.
  2. Import the modules it depends on (here the one providing ProjectService) in McpModule.

Rules

  • Context comes from the token, never from the model. context.userId and context.workspaceIds are built by McpAuthGuard. Narrow every query to context.workspaceIds, and never trust a user or workspace ID from the input without checking it against the context.
  • Write descriptions for the model. The tool description and the .describe() of each field are all it knows. Say what the tool returns and when to use it.
  • Return compact data. The result lands in the model's context window: return the fields it needs, and paginate long lists.
  • Throw McpToolError for expected failures (unknown ID, missing data). Its message reaches the model as an error result it can recover from. Other exceptions are logged and answered as internal errors.

Write tools

The kit ships none on purpose: a tool that changes data can be triggered by a prompt injection hidden in the data the model reads. To add one:

  1. Add a write scope to oauthScopeSchema and its consent wording to OAUTH_SCOPE_DESCRIPTIONS (packages/shared-models/src/oauth).
  2. Give the tool scope: 'write': it is only listed for tokens granted that scope.
  3. Set destructiveHint: true for write tools in mcp.service.ts, so clients ask the user before running them.

Rate limit

Each connected app is limited to 120 requests per minute, enough for an agent and a cap for one stuck in a loop. Requests over the limit get a 429.

Configuration

VariablePurpose
API_URLPublic origin of the API, without path. OAuth issuer and MCP host.
APP_URLPublic origin of the web app, which hosts the consent page.
OAUTH_SIGNING_SECRETSigns client IDs. Changing it invalidates registered clients, which simply register again.

API_URL must be reachable from the internet for claude.ai and ChatGPT. In production, it is the same public API URL Stripe calls for its webhooks.

Not implemented

  • Client ID Metadata Documents (CIMD): every current client supports dynamic registration.
  • The revocation endpoint (RFC 7009): users revoke from the Connected apps page.
  • Purging old authorization codes: rows are tiny and single-use. Add a cron job if the table grows.

Tests

  • Unit tests next to the code: PKCE, redirect URIs, client IDs, the guard.
  • src/mcp/mcp.e2e-spec.ts runs the whole OAuth and MCP flow against the test database (npm run test:e2e).