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:
29
src/app/api/settings/mcp-tokens/[id]/route.ts
Normal file
29
src/app/api/settings/mcp-tokens/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { requireUser } from "@/lib/authz"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* Revoke a token. Soft, so the row survives for the audit trail; verifyMcpToken
|
||||
* checks revokedAt on every request with no cache anywhere, so it takes effect
|
||||
* on the next call.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await requireUser()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
// Scoped to the caller, resolving to 404 rather than 403 - the convention
|
||||
// across the rest of the API.
|
||||
const { count } = await prisma.mcpAccessToken.updateMany({
|
||||
where: { id: params.id, userId: session.user.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
|
||||
if (count === 0) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
107
src/app/api/settings/mcp-tokens/route.ts
Normal file
107
src/app/api/settings/mcp-tokens/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { requireUser } from "@/lib/authz"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { generateMcpToken } from "@/lib/mcp/tokens"
|
||||
import { MCP_ALL_SCOPES, MCP_READ_SCOPES, mcpResourceUrl } from "@/lib/mcp/config"
|
||||
|
||||
/**
|
||||
* MCP access tokens.
|
||||
*
|
||||
* requireUser, not requireOwner: a token only ever reaches its own user's rows,
|
||||
* so a member connecting their own Claude or ChatGPT is no more privileged than
|
||||
* them logging into the web app.
|
||||
*/
|
||||
|
||||
/** Enough to cover a laptop, a phone, a desktop client and some spares. */
|
||||
const MAX_LIVE_TOKENS = 10
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
access: z.enum(["read", "read-write"]).default("read"),
|
||||
expiresInDays: z.union([z.literal(90), z.literal(365), z.null()]).default(90),
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
const session = await requireUser()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
const tokens = await prisma.mcpAccessToken.findMany({
|
||||
where: { userId: session.user.id, source: "pat", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
// The secret is not selectable because it is not stored - only its hash is.
|
||||
// This is the deliberate difference from /api/settings/api-keys, which
|
||||
// decrypts and masks on read.
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
prefix: true,
|
||||
scopes: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
tokens,
|
||||
serverUrl: mcpResourceUrl(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await requireUser()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = createSchema.safeParse(body ?? {})
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const live = await prisma.mcpAccessToken.count({
|
||||
where: { userId: session.user.id, source: "pat", revokedAt: null },
|
||||
})
|
||||
if (live >= MAX_LIVE_TOKENS) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You already have ${MAX_LIVE_TOKENS} active tokens. Revoke one before creating another.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, access, expiresInDays } = parsed.data
|
||||
const { raw, hash, prefix } = generateMcpToken()
|
||||
|
||||
const token = await prisma.mcpAccessToken.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
tokenHash: hash,
|
||||
prefix,
|
||||
name: name ?? null,
|
||||
scopes: access === "read-write" ? MCP_ALL_SCOPES : MCP_READ_SCOPES,
|
||||
source: "pat",
|
||||
expiresAt: expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
prefix: true,
|
||||
scopes: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// The only time the secret ever leaves this process.
|
||||
return NextResponse.json(
|
||||
{ token: { ...token, secret: raw }, serverUrl: mcpResourceUrl() },
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user