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:
160
src/lib/mcp/render.ts
Normal file
160
src/lib/mcp/render.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server"
|
||||
|
||||
/**
|
||||
* Shaping tool output.
|
||||
*
|
||||
* Every result carries both `content` text and `structuredContent`. The text is
|
||||
* what Claude actually reads; structuredContent is there for clients that parse
|
||||
* it. No `outputSchema` is declared anywhere - declaring one makes
|
||||
* structuredContent mandatory per spec and adds schema bloat to every tools/list,
|
||||
* whereas returning it undeclared is allowed.
|
||||
*
|
||||
* Text is compact lines, never a JSON dump. The id comes first on every line so
|
||||
* the model can quote it straight into the next call - every write tool takes one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* claude.ai accepts about 150,000 characters per tool result. This budget is far
|
||||
* lower on purpose: the model pays for every character on every subsequent turn,
|
||||
* so a result worth truncating is a result worth paginating.
|
||||
*/
|
||||
export const MAX_TOOL_TEXT = 40_000
|
||||
|
||||
/** Hard ceiling on any list tool's page size. The REST API allows 100. */
|
||||
export const MAX_PAGE_SIZE = 50
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export function truncateToolText(text: string, limit = MAX_TOOL_TEXT): string {
|
||||
if (text.length <= limit) return text
|
||||
const cut = text.slice(0, limit)
|
||||
// Don't strand a half-written line - the model may try to parse it as an id.
|
||||
const lastBreak = cut.lastIndexOf("\n")
|
||||
const body = lastBreak > limit * 0.8 ? cut.slice(0, lastBreak) : cut
|
||||
return `${body}\n… output truncated. Narrow the search or request the next page.`
|
||||
}
|
||||
|
||||
export function ok(
|
||||
text: string,
|
||||
structuredContent?: Record<string, unknown>
|
||||
): CallToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: truncateToolText(text) }],
|
||||
...(structuredContent ? { structuredContent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(message: string): CallToolResult {
|
||||
return { content: [{ type: "text", text: message }], isError: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailer models follow reliably. Always emitted by list tools, even on a
|
||||
* single page, so "is there more" never needs a second call to answer.
|
||||
*/
|
||||
export function paginationTrailer(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
total: number
|
||||
): string {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (total === 0) return "no matches"
|
||||
const more =
|
||||
page < totalPages ? ` · pass page=${page + 1} for more` : " · end of results"
|
||||
return `page ${page} of ${totalPages} · ${total} total${more}`
|
||||
}
|
||||
|
||||
/** Joins non-empty parts with a separator, dropping blanks. */
|
||||
function join(parts: (string | null | undefined)[], sep = " · "): string {
|
||||
return parts.filter((p): p is string => !!p && p.length > 0).join(sep)
|
||||
}
|
||||
|
||||
export function formatAbv(abv: number | null | undefined): string | null {
|
||||
return typeof abv === "number" ? `${abv.toFixed(1)}%` : null
|
||||
}
|
||||
|
||||
export function formatDrinkLine(d: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
subType?: string | null
|
||||
brewery?: string | null
|
||||
region?: string | null
|
||||
abv?: number | null
|
||||
avgRating?: number | null
|
||||
ratingCount?: number
|
||||
}): string {
|
||||
const kind = d.subType ? `${d.type} / ${d.subType}` : d.type
|
||||
const rating =
|
||||
d.avgRating != null && d.ratingCount
|
||||
? `★${d.avgRating.toFixed(1)} (${d.ratingCount})`
|
||||
: "unrated"
|
||||
return `[${d.id}] ${d.name} — ${join([
|
||||
kind,
|
||||
d.brewery,
|
||||
d.region,
|
||||
formatAbv(d.abv),
|
||||
rating,
|
||||
])}`
|
||||
}
|
||||
|
||||
export function formatBarItemLine(b: {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
quantity: string
|
||||
notes?: string | null
|
||||
}): string {
|
||||
return `[${b.id}] ${b.name} — ${join([b.category, b.quantity, b.notes])}`
|
||||
}
|
||||
|
||||
export function formatRatingLine(r: {
|
||||
id: string
|
||||
score: number
|
||||
wouldReorder: boolean
|
||||
location?: string | null
|
||||
notes?: string | null
|
||||
createdAt: Date
|
||||
drink?: { name: string } | null
|
||||
}): string {
|
||||
const when = r.createdAt.toISOString().slice(0, 10)
|
||||
return `[${r.id}] ${r.drink?.name ?? "?"} — ${join([
|
||||
`★${r.score}`,
|
||||
when,
|
||||
r.location,
|
||||
r.wouldReorder ? "would reorder" : null,
|
||||
r.notes,
|
||||
])}`
|
||||
}
|
||||
|
||||
export function formatRecipeLine(r: {
|
||||
id: string
|
||||
title: string
|
||||
missingCount: number
|
||||
ingredients: { name: string; available: boolean }[]
|
||||
glassware?: string | null
|
||||
}): string {
|
||||
const status =
|
||||
r.missingCount === 0
|
||||
? "can make now"
|
||||
: `missing ${r.missingCount}: ${r.ingredients
|
||||
.filter((i) => !i.available)
|
||||
.map((i) => i.name)
|
||||
.join(", ")}`
|
||||
return `[${r.id}] ${r.title} — ${join([
|
||||
`${r.ingredients.length} ingredients`,
|
||||
status,
|
||||
r.glassware,
|
||||
])}`
|
||||
}
|
||||
|
||||
export function formatWishlistLine(w: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
subType?: string | null
|
||||
brewery?: string | null
|
||||
notes?: string | null
|
||||
}): string {
|
||||
const kind = w.subType ? `${w.type} / ${w.subType}` : w.type
|
||||
return `[${w.id}] ${w.name} — ${join([kind, w.brewery, w.notes])}`
|
||||
}
|
||||
Reference in New Issue
Block a user