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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user