Files
drinktracker/src/lib/mcp/render.ts
JP 4fd339dabc 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
2026-08-09 04:29:54 +00:00

161 lines
4.7 KiB
TypeScript

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])}`
}