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

@@ -0,0 +1,314 @@
"use client"
import { useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Select, SelectOption } from "@/components/ui/select"
import { Check, Copy, Loader2, Plug, Trash2 } from "lucide-react"
interface McpToken {
id: string
name: string | null
prefix: string
scopes: string[]
lastUsedAt: string | null
expiresAt: string | null
createdAt: string
}
interface TokensResponse {
tokens: McpToken[]
serverUrl: string
}
interface CreatedToken extends McpToken {
secret: string
}
const ACCESS_OPTIONS = [
{ value: "read", label: "Read only" },
{ value: "read-write", label: "Read and write" },
]
const EXPIRY_OPTIONS = [
{ value: "90", label: "90 days" },
{ value: "365", label: "1 year" },
{ value: "never", label: "Never" },
]
/**
* Connecting an AI assistant to this account.
*
* Not gated on ownership, unlike the AI Gateway card: a token only ever reaches
* its own user's rows, and it spends nothing.
*/
export function McpTokensCard() {
const queryClient = useQueryClient()
const [name, setName] = useState("")
const [access, setAccess] = useState("read")
const [expiry, setExpiry] = useState("90")
const [created, setCreated] = useState<CreatedToken | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const { data, isLoading } = useQuery<TokensResponse>({
queryKey: ["mcp-tokens"],
queryFn: async () => {
const res = await fetch("/api/settings/mcp-tokens")
if (!res.ok) throw new Error("Failed to load tokens")
return res.json()
},
})
const create = useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/mcp-tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: name.trim() || undefined,
access,
expiresInDays: expiry === "never" ? null : Number(expiry),
}),
})
const body = await res.json()
if (!res.ok) throw new Error(body.error ?? "Failed to create token")
return body as { token: CreatedToken; serverUrl: string }
},
onSuccess: (body) => {
setCreated(body.token)
setName("")
queryClient.invalidateQueries({ queryKey: ["mcp-tokens"] })
},
})
const revoke = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/settings/mcp-tokens/${id}`, {
method: "DELETE",
})
if (!res.ok) throw new Error("Failed to revoke token")
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mcp-tokens"] })
},
})
const serverUrl = data?.serverUrl ?? ""
async function copy(key: string, value: string) {
await navigator.clipboard.writeText(value)
setCopied(key)
setTimeout(() => setCopied(null), 2000)
}
const connectCommand = created
? `claude mcp add --transport http drinktracker ${serverUrl} --header "Authorization: Bearer ${created.secret}" -s user`
: ""
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="h-5 w-5" />
AI assistant access
</CardTitle>
<CardDescription>
Connect Claude or ChatGPT to this account so it can read and update your
drinks, bar and recipes. Each token reaches only your own data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{created && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/5 p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">Your new token</p>
<p className="text-sm text-muted-foreground">
Copy it now. It is stored only as a hash, so it cannot be shown
again.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setCreated(null)}
aria-label="Dismiss"
>
Done
</Button>
</div>
<div className="flex gap-2">
<code className="flex-1 overflow-x-auto rounded bg-muted px-3 py-2 text-xs">
{created.secret}
</code>
<Button
variant="outline"
size="sm"
onClick={() => copy("secret", created.secret)}
>
{copied === "secret" ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Connect Claude Code</p>
<div className="flex gap-2">
<code className="flex-1 overflow-x-auto whitespace-pre rounded bg-muted px-3 py-2 text-xs">
{connectCommand}
</code>
<Button
variant="outline"
size="sm"
onClick={() => copy("cmd", connectCommand)}
>
{copied === "cmd" ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="mcp-name">Name</Label>
<Input
id="mcp-name"
placeholder="Laptop"
value={name}
maxLength={100}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mcp-access">Access</Label>
<Select
id="mcp-access"
value={access}
onChange={(e) => setAccess(e.target.value)}
>
{ACCESS_OPTIONS.map((o) => (
<SelectOption key={o.value} value={o.value}>
{o.label}
</SelectOption>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="mcp-expiry">Expires</Label>
<Select
id="mcp-expiry"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
>
{EXPIRY_OPTIONS.map((o) => (
<SelectOption key={o.value} value={o.value}>
{o.label}
</SelectOption>
))}
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={() => create.mutate()} disabled={create.isPending}>
{create.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create token
</Button>
{create.isError && (
<p className="text-sm text-destructive">
{(create.error as Error).message}
</p>
)}
</div>
<div className="space-y-2">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading</p>
)}
{!isLoading && !data?.tokens.length && (
<p className="text-sm text-muted-foreground">
No tokens yet. Create one to connect an assistant.
</p>
)}
{data?.tokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{token.name ?? "Unnamed token"}
</span>
<code className="text-xs text-muted-foreground">
dtk_{token.prefix}
</code>
<Badge variant="secondary">
{token.scopes.some((s) => s.endsWith(":write"))
? "read/write"
: "read only"}
</Badge>
<ExpiryBadge expiresAt={token.expiresAt} />
</div>
<p className="mt-1 text-xs text-muted-foreground">
{token.lastUsedAt
? `Last used ${new Date(token.lastUsedAt).toLocaleDateString()}`
: "Never used"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate(token.id)}
disabled={revoke.isPending}
>
<Trash2 className="mr-1 h-4 w-4" />
Revoke
</Button>
</div>
))}
</div>
{serverUrl && (
<p className="text-xs text-muted-foreground">
Server URL: <code>{serverUrl}</code>
</p>
)}
</CardContent>
</Card>
)
}
function ExpiryBadge({ expiresAt }: { expiresAt: string | null }) {
if (!expiresAt) return <Badge variant="outline">no expiry</Badge>
const days = Math.ceil(
(new Date(expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000)
)
if (days <= 0) return <Badge variant="destructive">expired</Badge>
// Flagged early enough to rotate before a client starts failing.
if (days <= 14) return <Badge variant="destructive">{days}d left</Badge>
return <Badge variant="outline">{days}d left</Badge>
}

View File

@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react"
import { BackupRestore } from "@/components/settings/backup-restore"
import { McpTokensCard } from "@/components/settings/mcp-tokens-card"
import { DeleteAccountCard } from "@/components/settings/delete-account-card"
interface ApiKeyInfo {
@@ -140,6 +141,10 @@ export function SettingsClient({ isOwner }: { isOwner: boolean }) {
</CardContent>
</Card>
{/* Available to every member: an MCP token only ever reaches its own
user's rows, and unlike the gateway key it spends nothing. */}
<McpTokensCard />
{/* Backup & Restore Section */}
<BackupRestore />