Dentolize · Claude MCP Server (Phase 1) Walkthrough
On this pageTermsData model additionsTwo new database roles (not Prisma models — Postgres roles)

Glossary & data model

Terms

MCP (Model Context Protocol) — the open protocol Claude uses to call tools exposed by an external server. packages/claude-mcp implements one MCP server, backed by @modelcontextprotocol/sdk, that Claude connects to over HTTP (POST /mcp).

Resource server — in OAuth terms, the service that holds the protected data and checks tokens. Here, that's packages/claude-mcp itself: it validates a bearer token and serves tool calls, but never issues a token.

Authorization server — the service that authenticates the user and issues tokens. Here, that's the new /oauth/* routes inside the existing packages/server API — not a separate service.

Scope — a named permission a token can carry, one per data domain (inventory:read, finance:read, appointments:read, patients:read, clinical:read). Only inventory:read has any tools behind it in this PR; the rest are reserved for future phases.

PKCE (Proof Key for Code Exchange) — the mechanism that lets a public client (one with no secret, like Claude) safely exchange an authorization code for a token. The client proves it's the same party that started the flow, without ever holding a shared secret.

Authorization code — a short-lived, single-use value the authorization server hands back after a doctor approves a connection. Exchanged once for an access and refresh token, then discarded (consumedAt is set to prevent reuse).

Access token / refresh token — the access token is what Claude actually sends on each tool call (short-lived, one hour by default). The refresh token renews it without asking the doctor to log in again (idle timeout of 7 days, absolute cap of 30 days). Both are opaque random strings, stored only as SHA-256 hashes — never as JWTs.

Row-level security (RLS) — a Postgres feature that filters which rows a query can see, enforced by the database itself rather than by application code. Used here to guarantee the mcp_readonly login can only ever see rows belonging to one clinic per transaction.

Tenant / companyId — Dentolize is multi-tenant: every clinic is a Company row, and almost every business table carries a companyId column (directly, or reachable through a parent table). "Tenant isolation" means no query run through the MCP can ever return rows from more than one Company at a time.

app.company_id — the specific Postgres session setting that carries the current tenant scope for the duration of one transaction. Every row-level security policy in this PR reads this setting; nothing else determines which clinic a query can see.

Flow cookie — a short-lived, SameSite=Lax cookie set at the start of an authorization attempt, distinct from and in addition to the doctor's normal Dentolize session cookie. Exists specifically because the session cookie (SameSite=Strict) doesn't survive the cross-site redirect that starts the flow.

Consent screen — the plain HTML page (not a clinic-web React screen) a doctor sees after logging in, naming exactly which scopes they're about to grant. Server-rendered specifically so it doesn't depend on a JavaScript bundle loading correctly.

Parity test — a test that runs the MCP tool's query and the API's own equivalent query against the same data and asserts they agree. The mechanism this PR uses to guarantee "the MCP never disagrees with clinic-web about a number."

Leak suite — the test suite (src/db/rls.test.ts) that seeds two separate clinics and asserts a query scoped to one never returns a row belonging to the other.

Data model additions

Three new tables, added in packages/prisma/migrations/20260908160156_add_oauth_tables/ and defined in packages/prisma/schema.prisma:7316-7376. No existing table gains a column; User gains two relation fields (oauthCodes, oauthTokens) which are Prisma's way of expressing the other side of a foreign key, not real columns.

TablePurposeKey fields
OAuthClientA registered application (Claude, or any future MCP client)clientId (unique), clientName, redirectUris[], scopes[]
OAuthAuthorizationCodeA short-lived, single-use code exchanged for tokenscodeHash (unique), userId, companyId, codeChallenge, expiresAt, consumedAt
OAuthTokenAn access or refresh tokentokenHash (unique), type, userId, companyId, expiresAt, absoluteExpiresAt, revokedAt

Both OAuthAuthorizationCode and OAuthToken carry companyId directly — each one names exactly one clinic, never a list. Deleting a User cascades to delete their codes and tokens (onDelete: Cascade), so removing a user's Dentolize account removes their Claude connections in the same operation.

Two new database roles (not Prisma models — Postgres roles)

RoleCan readCan writeUsed by
mcp_authThe three OAuth tables onlyNothingToken validation (src/auth/oauth.ts)
mcp_readonlyEvery business/clinical table, denied the OAuth tablesNothingEvery tool (src/tools/**)

Neither role can write anything, anywhere — enforced by Postgres grants, not by application code.