Add MCP server so Claude and ChatGPT can read and write drink data

Exposes the collection over the Model Context Protocol at /api/mcp, with 22
tools covering drinks, ratings, bar inventory, recipes, wishlist and taste
preferences, plus search/fetch aliases for ChatGPT's deep-research mode.

Authentication is a bearer token, the app's first header-borne credential -
every other route derives identity from the NextAuth cookie, which a machine
client cannot present. /api/mcp sits under the middleware's /api exclusion so
it can answer a JSON 401 with an RFC 9728 WWW-Authenticate challenge instead
of an HTML redirect to /login.

Tokens are stored as a SHA-256 hash rather than plaintext like Invite.token
and PasswordReset.token. Those are single-use and short-lived; this one is
long-lived and grants read/write over a whole collection, and the nightly
pg_dump keeps 14 days of history. Not encrypt(), which is reversible AES and
right only for outbound keys we must replay; not bcrypt, which cannot be
indexed and would turn verification into a table scan per request.

verifyMcpToken joins User.status on every call, mirroring the jwt callback, so
suspending a member kills their MCP access immediately rather than leaving the
token as a documented way to outlive suspension. It fails closed on a database
error, deliberately unlike the jwt callback, which keeps the session because a
throw there would sign out every user at once.

No tool reaches the Switchboard gateway. Claude and ChatGPT are language
models already, so they can reason over a bar inventory without the app paying
to do it a second time, and a remote client looping a vision call is not a
failure mode worth having. Account deletion, restore, gateway keys, admin
routes and shared-list creation are excluded too.

The OAuth models ship now but are unused; the token endpoint will write the
same McpAccessToken rows, so adding it later touches no verification code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
This commit is contained in:
JP
2026-08-09 04:29:54 +00:00
parent 13793d43ca
commit 4fd339dabc
23 changed files with 2631 additions and 0 deletions

View File

@@ -58,6 +58,11 @@ model User {
invitesCreated Invite[] @relation("InviteCreator")
redemption InviteRedemption?
passwordResets PasswordReset[]
mcpTokens McpAccessToken[]
mcpAuditLogs McpAuditLog[]
oauthAuthCodes OAuthAuthCode[]
oauthGrants OAuthGrant[]
}
// ─── Invites ─────────────────────────────────────────────────────
@@ -111,6 +116,143 @@ model PasswordReset {
@@index([userId])
}
// ─── MCP access ──────────────────────────────────────────────────
// The app's first header-borne credential. Every other route derives identity
// from the NextAuth JWT cookie, which a machine client (claude.ai, ChatGPT,
// Claude Code) cannot present.
/// Bearer token for the MCP endpoint. Two producers, one table: a token minted
/// by hand in Settings (source "pat") and one issued by the OAuth token endpoint
/// (source "oauth") are the same row shape, so verification has a single path.
///
/// Stored as a SHA-256 hash, unlike Invite.token and PasswordReset.token which
/// are plaintext. Those are single-use and short-lived; this one is long-lived
/// and grants full read/write over a user's whole collection, and the nightly
/// pg_dump keeps 14 days of history.
model McpAccessToken {
id String @id @default(cuid())
userId String
/// sha256 hex of the raw secret - never the secret itself
tokenHash String @unique
/// First 8 chars of the secret, so the UI can tell two tokens apart
prefix String
/// User-supplied label, or the OAuth client name
name String?
scopes String[]
/// "pat" | "oauth"
source String @default("pat")
/// Set only for OAuth-issued tokens; revoking the grant revokes these with it
grantId String?
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
grant OAuthGrant? @relation(fields: [grantId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([grantId])
@@index([expiresAt])
}
/// One row per MCP tool call. Modelled on AiCall: the arguments are deliberately
/// not recorded - they carry free-text tasting notes and could carry anything.
model McpAuditLog {
id String @id @default(cuid())
userId String
tokenId String?
tool String
ok Boolean
errorCode String?
/// Row the call created, updated or deleted
recordId String?
durationMs Int?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([createdAt])
}
/// A client registered through RFC 7591 dynamic registration. Not user-owned -
/// registrations are global, and a user's access is revoked by cascading their
/// OAuthGrant instead.
model OAuthClient {
id String @id @default(cuid())
clientId String @unique
clientName String
redirectUris String[]
grantTypes String[] @default(["authorization_code", "refresh_token"])
responseTypes String[] @default(["code"])
/// Public clients only - this server stores no client secrets
tokenEndpointAuthMethod String @default("none")
scope String?
clientUri String?
logoUri String?
softwareId String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
authCodes OAuthAuthCode[]
grants OAuthGrant[]
@@index([createdAt])
}
/// Authorization code, hashed and single-use. Consumed with a guarded updateMany
/// so a replayed code cannot mint a second token.
model OAuthAuthCode {
id String @id @default(cuid())
codeHash String @unique
clientId String
userId String
/// Exactly what was sent to /oauth/authorize - re-checked at the token endpoint
redirectUri String
/// RFC 8707 audience binding
resource String?
scopes String[]
codeChallenge String
codeChallengeMethod String @default("S256")
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
/// A user's standing consent for one client. Holds the refresh token, since
/// rotation replaces it while the grant itself persists.
model OAuthGrant {
id String @id @default(cuid())
clientId String
userId String
scopes String[]
refreshHash String? @unique
/// One generation back. A hit here means a rotated token was replayed, which
/// is a theft signal - revoke the whole grant rather than issuing again.
refreshPrevHash String?
refreshExpiresAt DateTime?
revokedAt DateTime?
lastUsedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessTokens McpAccessToken[]
@@unique([clientId, userId])
@@index([userId])
}
model Account {
id String @id @default(cuid())
userId String