/** * MCP tools for rChats (multiplayer chat channels). * forceAuth=true — chat messages are always sensitive. * * Tools: rchats_list_channels, rchats_get_channel, rchats_list_messages, * rchats_list_thread_messages, rchats_list_dms, rchats_send_message */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { chatsDirectoryDocId, chatChannelDocId, dmChannelDocId } from "../../modules/rchats/schemas"; import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility } from "../../shared/membrane"; export function registerChatsTools(server: McpServer, syncServer: SyncServer) { server.tool( "rchats_list_channels", "List chat channels in a space", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token (required — chat data is private)"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(chatsDirectoryDocId(space)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ channels: [] }) }] }; const channels = filterArrayByVisibility(Object.values(doc.channels || {}), access.role).map(ch => ({ id: ch.id, name: ch.name, description: ch.description, isPrivate: ch.isPrivate, isDm: ch.isDm, createdBy: ch.createdBy, createdAt: ch.createdAt, updatedAt: ch.updatedAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(channels, null, 2) }] }; }, ); server.tool( "rchats_get_channel", "Get channel details including members", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), }, async ({ space, token, channel_id }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(chatChannelDocId(space, channel_id)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; const members = Object.values(doc.members || {}); const messageCount = Object.keys(doc.messages || {}).length; return { content: [{ type: "text" as const, text: JSON.stringify({ channelId: doc.channelId, members, messageCount, isDm: doc.isDm }, null, 2), }], }; }, ); server.tool( "rchats_list_messages", "List recent messages in a chat channel (newest first)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), limit: z.number().optional().describe("Max messages to return (default 50)"), }, async ({ space, token, channel_id, limit }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(chatChannelDocId(space, channel_id)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role) .filter(m => !m.threadId) // Only top-level messages .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit || 50); const result = messages.map(m => ({ id: m.id, authorName: m.authorName, content: m.content, replyTo: m.replyTo, reactions: m.reactions || {}, editedAt: m.editedAt, createdAt: m.createdAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; }, ); server.tool( "rchats_list_thread_messages", "List messages in a thread (replies to a root message)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), thread_id: z.string().describe("Root message ID of the thread"), }, async ({ space, token, channel_id, thread_id }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(chatChannelDocId(space, channel_id)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; const rootMsg = doc.messages?.[thread_id]; const replies = filterArrayByVisibility( Object.values(doc.messages || {}).filter(m => m.threadId === thread_id), access.role, ).sort((a, b) => a.createdAt - b.createdAt); const result = { rootMessage: rootMsg ? { id: rootMsg.id, authorName: rootMsg.authorName, content: rootMsg.content, createdAt: rootMsg.createdAt } : null, replies: replies.map(m => ({ id: m.id, authorName: m.authorName, content: m.content, createdAt: m.createdAt, })), replyCount: replies.length, }; return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; }, ); server.tool( "rchats_list_dms", "List DM channels for the authenticated user", { 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 doc = syncServer.getDoc(chatsDirectoryDocId(space)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ dms: [] }) }] }; const userDid = String(access.claims?.did || access.claims?.sub || ''); const dms = Object.values(doc.channels || {}) .filter(ch => ch.isDm && ch.id.includes(userDid)) .map(ch => ({ id: ch.id, createdAt: ch.createdAt })); return { content: [{ type: "text" as const, text: JSON.stringify(dms, null, 2) }] }; }, ); server.tool( "rchats_send_message", "Send a message to a chat channel (write operation — requires auth token)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), content: z.string().describe("Message content"), }, async ({ space, token, channel_id, content }) => { const access = await resolveAccess(token, space, true, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = chatChannelDocId(space, channel_id); let doc = syncServer.getDoc(docId); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; const id = crypto.randomUUID(); const authorDid = String(access.claims?.did || access.claims?.sub || ''); const authorName = String(access.claims?.username || 'MCP'); syncServer.changeDoc(docId, `mcp send message ${id}`, (d) => { d.messages[id] = { id, channelId: channel_id, authorId: authorDid, authorName, content, replyTo: null, reactions: {}, transclusions: [], editedAt: null, createdAt: Date.now(), }; }); return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messageId: id }) }] }; }, ); }