rspace-online/server/mcp-tools/rinbox.ts

183 lines
6.1 KiB
TypeScript

/**
* 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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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) }] };
},
);
}