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 onPOST /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).
| Client | How |
|---|---|
| claude.ai | Settings, Connectors, Add custom connector, paste the URL. Needs a public URL. |
| Claude Code | claude mcp add --transport http dopamine http://localhost:8080/mcp |
| Cursor | ~/.cursor/mcp.json: { "mcpServers": { "dopamine": { "url": "http://localhost:8080/mcp" } } } |
| ChatGPT | Settings, Connectors, Create, paste the URL. Needs a public URL. |
| MCP Inspector | npx @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 tokenNo 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_idis a JWT signed withOAUTH_SIGNING_SECRETthat 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 inOAuthGrantrows. - Opaque tokens, stored hashed. One
OAuthGrantrow 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 onerror.
Tools shipped with the kit
All read-only (scope read), in src/mcp/toolsets/core.toolset.ts:
| Tool | Returns |
|---|---|
whoami | The user, the workspaces this connection can read, scopes |
list_workspaces | Granted workspaces with member count and subscription plan |
list_workspace_members | Active members of a granted workspace |
get_credit_balance | Credit 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
- Create an
@Injectable()class implementingMcpToolset, next tocore.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);
},
}),
];
}- Add it to
MCP_TOOLSETSinmcp-toolsets.ts. - Import the modules it depends on (here the one providing
ProjectService) inMcpModule.
Rules
- Context comes from the token, never from the model.
context.userIdandcontext.workspaceIdsare built byMcpAuthGuard. Narrow every query tocontext.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
descriptionand 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
McpToolErrorfor 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:
- Add a
writescope tooauthScopeSchemaand its consent wording toOAUTH_SCOPE_DESCRIPTIONS(packages/shared-models/src/oauth). - Give the tool
scope: 'write': it is only listed for tokens granted that scope. - Set
destructiveHint: truefor write tools inmcp.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
| Variable | Purpose |
|---|---|
API_URL | Public origin of the API, without path. OAuth issuer and MCP host. |
APP_URL | Public origin of the web app, which hosts the consent page. |
OAUTH_SIGNING_SECRET | Signs 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.tsruns the whole OAuth and MCP flow against the test database (npm run test:e2e).