/** * MCP tools for rInbox (email mailboxes & threads). * * ALL tools use forceAuth=true — always requires token+member * regardless of space visibility (email content is sensitive). * * Tools: rinbox_list_mailboxes, rinbox_list_threads, * rinbox_get_thread, rinbox_list_approvals */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { mailboxDocId } from "../../modules/rinbox/schemas"; import type { MailboxDoc } from "../../modules/rinbox/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; const MAILBOX_PREFIX = ":inbox:mailboxes:"; /** Find all mailbox docIds for a space. */ function findMailboxDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${MAILBOX_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } export function registerInboxTools(server: McpServer, syncServer: SyncServer) { server.tool( "rinbox_list_mailboxes", "List mailboxes in a space (requires auth + membership — email content is sensitive)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findMailboxDocIds(syncServer, space); const mailboxes = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.mailbox) continue; mailboxes.push({ id: doc.mailbox.id, name: doc.mailbox.name, slug: doc.mailbox.slug, email: doc.mailbox.email, threadCount: Object.keys(doc.threads || {}).length, approvalCount: Object.keys(doc.approvals || {}).length, }); } return { content: [{ type: "text", text: JSON.stringify(mailboxes, null, 2) }] }; }, ); server.tool( "rinbox_list_threads", "List email threads in a mailbox (subjects only, no body content)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), mailbox_slug: z.string().optional().describe("Filter by mailbox slug (searches all if omitted)"), status: z.string().optional().describe("Filter by status"), search: z.string().optional().describe("Search in subject/from"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, mailbox_slug, status, search, limit }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = mailbox_slug ? findMailboxDocIds(syncServer, space).filter(id => id.endsWith(`:${mailbox_slug}`)) : findMailboxDocIds(syncServer, space); let threads: Array<{ id: string; mailboxId: string; subject: string; fromAddress: string | null; fromName: string | null; status: string; isRead: boolean; isStarred: boolean; receivedAt: number; }> = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.threads) continue; for (const t of Object.values(doc.threads)) { threads.push({ id: t.id, mailboxId: t.mailboxId, subject: t.subject, fromAddress: t.fromAddress, fromName: t.fromName, status: t.status, isRead: t.isRead, isStarred: t.isStarred, receivedAt: t.createdAt, }); } } if (status) threads = threads.filter(t => t.status === status); if (search) { const q = search.toLowerCase(); threads = threads.filter(t => t.subject.toLowerCase().includes(q) || (t.fromAddress && t.fromAddress.toLowerCase().includes(q)) || (t.fromName && t.fromName.toLowerCase().includes(q)), ); } threads.sort((a, b) => b.receivedAt - a.receivedAt); threads = threads.slice(0, limit || 50); return { content: [{ type: "text", text: JSON.stringify(threads, null, 2) }] }; }, ); server.tool( "rinbox_get_thread", "Get a full email thread including body text (omits bodyHtml for size)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), thread_id: z.string().describe("Thread ID"), }, async ({ space, token, thread_id }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); for (const docId of findMailboxDocIds(syncServer, space)) { const doc = syncServer.getDoc(docId); const thread = doc?.threads?.[thread_id]; if (thread) { // Omit bodyHtml to reduce payload size const { bodyHtml, ...safe } = thread; return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] }; } } return { content: [{ type: "text", text: JSON.stringify({ error: "Thread not found" }) }] }; }, ); server.tool( "rinbox_list_approvals", "List pending email approvals (draft reviews awaiting signatures)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), mailbox_slug: z.string().optional().describe("Filter by mailbox slug"), }, async ({ space, token, mailbox_slug }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = mailbox_slug ? findMailboxDocIds(syncServer, space).filter(id => id.endsWith(`:${mailbox_slug}`)) : findMailboxDocIds(syncServer, space); const approvals = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.approvals) continue; for (const a of Object.values(doc.approvals)) { if (a.status !== "pending") continue; approvals.push({ id: a.id, mailboxId: a.mailboxId, subject: a.subject, toAddresses: a.toAddresses, authorId: a.authorId, requiredSignatures: a.requiredSignatures, currentSignatures: a.signatures?.length ?? 0, createdAt: a.createdAt, }); } } return { content: [{ type: "text", text: JSON.stringify(approvals, null, 2) }] }; }, ); }