/** * MCP tools for rBooks (PDF library). * * Tools: rbooks_list_books, rbooks_get_book * Omits pdfPath from responses. */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { booksCatalogDocId } from "../../modules/rbooks/schemas"; import type { BooksCatalogDoc } from "../../modules/rbooks/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; export function registerBooksTools(server: McpServer, syncServer: SyncServer) { server.tool( "rbooks_list_books", "List books in a space's library", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), search: z.string().optional().describe("Search in title/author/tags"), featured_only: z.boolean().optional().describe("Only featured books"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, search, featured_only, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(booksCatalogDocId(space)); if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No books found" }) }] }; let books = Object.values(doc.items || {}); if (featured_only) books = books.filter(b => b.featured); if (search) { const q = search.toLowerCase(); books = books.filter(b => b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q) || b.tags.some(t => t.toLowerCase().includes(q)), ); } books.sort((a, b) => b.viewCount - a.viewCount); books = books.slice(0, limit || 50); const summary = books.map(b => ({ id: b.id, slug: b.slug, title: b.title, author: b.author, description: (b.description || "").slice(0, 200), pageCount: b.pageCount, tags: b.tags, license: b.license, featured: b.featured, viewCount: b.viewCount, downloadCount: b.downloadCount, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rbooks_get_book", "Get full metadata for a specific book (omits pdfPath)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), book_id: z.string().describe("Book ID"), }, async ({ space, token, book_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(booksCatalogDocId(space)); const book = doc?.items?.[book_id]; if (!book) return { content: [{ type: "text", text: JSON.stringify({ error: "Book not found" }) }] }; const { pdfPath, ...safe } = book; return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] }; }, ); }