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