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:
314
src/components/settings/mcp-tokens-card.tsx
Normal file
314
src/components/settings/mcp-tokens-card.tsx
Normal 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>
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user