From 2e8e702d75ace059f06eea43e3be7ae1620fd23d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 10 Apr 2026 10:25:17 -0400 Subject: [PATCH] feat(mcp): 101 MCP tools across all 35 rApps + security hardening + MI integration - Add centralized auth helper (_auth.ts) with resolveAccess() enforcing space visibility (public/permissioned/private) and role-based access - Retrofit 5 existing tool groups (rcal, rnotes, rtasks, rwallet, spaces) with resolveAccess gates - Add 30 new MCP tool files covering all remaining rApps: rsocials, rnetwork, rinbox, rtime, rfiles, rschedule, rvote, rchoices, rtrips, rcart, rexchange, rbnb, rvnb, crowdsurf, rbooks, rpubs, rmeets, rtube, rswag, rdesign, rsplat, rphotos, rflows, rdocs, rdata, rforum, rchats, rmaps, rsheet, rgov - Add ForMI data exports to all module mod.ts files - Wire 6 core modules into MI context (mi-data-queries.ts, mi-routes.ts) - forceAuth for sensitive modules (rinbox, rchats) - Omit sensitive fields (storagePath, fileHash, bodyHtml) from responses Co-Authored-By: Claude Opus 4.6 --- modules/crowdsurf/mod.ts | 22 +++ modules/rbnb/mod.ts | 12 ++ modules/rbooks/mod.ts | 11 ++ modules/rcart/mod.ts | 13 ++ modules/rchats/mod.ts | 132 ++++++++++++++ modules/rchoices/mod.ts | 11 ++ modules/rdata/mod.ts | 6 + modules/rdesign/mod.ts | 44 +++++ modules/rdocs/mod.ts | 80 +++++++++ modules/rexchange/mod.ts | 11 ++ modules/rfiles/mod.ts | 41 +++++ modules/rflows/mod.ts | 11 ++ modules/rforum/mod.ts | 13 ++ modules/rgov/mod.ts | 16 ++ modules/rinbox/mod.ts | 37 ++++ modules/rmaps/mod.ts | 164 +++++++++++++++++ modules/rmeets/mod.ts | 76 ++++++++ modules/rnetwork/mod.ts | 26 +++ modules/rphotos/mod.ts | 122 +++++++++++++ modules/rpubs/mod.ts | 81 +++++++++ modules/rschedule/mod.ts | 31 ++++ modules/rsheet/mod.ts | 108 ++++++++++++ modules/rsocials/mod.ts | 29 +++ modules/rspace/mod.ts | 17 ++ modules/rsplat/mod.ts | 11 ++ modules/rswag/mod.ts | 60 +++++++ modules/rtime/mod.ts | 30 ++++ modules/rtrips/mod.ts | 13 ++ modules/rtube/mod.ts | 59 +++++++ modules/rvnb/mod.ts | 14 ++ modules/rvote/mod.ts | 13 ++ modules/rwallet/mod.ts | 17 ++ server/index.ts | 4 + server/mcp-server.ts | 118 +++++++++++++ server/mcp-tools/_auth.ts | 102 +++++++++++ server/mcp-tools/crowdsurf.ts | 87 +++++++++ server/mcp-tools/rbnb.ts | 125 +++++++++++++ server/mcp-tools/rbooks.ts | 78 ++++++++ server/mcp-tools/rcal.ts | 219 +++++++++++++++++++++++ server/mcp-tools/rcart.ts | 151 ++++++++++++++++ server/mcp-tools/rchats.ts | 96 ++++++++++ server/mcp-tools/rchoices.ts | 129 ++++++++++++++ server/mcp-tools/rdata.ts | 40 +++++ server/mcp-tools/rdesign.ts | 70 ++++++++ server/mcp-tools/rdocs.ts | 39 ++++ server/mcp-tools/rexchange.ts | 141 +++++++++++++++ server/mcp-tools/rfiles.ts | 161 +++++++++++++++++ server/mcp-tools/rflows.ts | 80 +++++++++ server/mcp-tools/rforum.ts | 70 ++++++++ server/mcp-tools/rgov.ts | 81 +++++++++ server/mcp-tools/rinbox.ts | 182 +++++++++++++++++++ server/mcp-tools/rmaps.ts | 79 +++++++++ server/mcp-tools/rmeets.ts | 62 +++++++ server/mcp-tools/rnetwork.ts | 99 +++++++++++ server/mcp-tools/rnotes.ts | 232 ++++++++++++++++++++++++ server/mcp-tools/rphotos.ts | 55 ++++++ server/mcp-tools/rpubs.ts | 81 +++++++++ server/mcp-tools/rschedule.ts | 190 ++++++++++++++++++++ server/mcp-tools/rsheet.ts | 78 ++++++++ server/mcp-tools/rsocials.ts | 144 +++++++++++++++ server/mcp-tools/rsplat.ts | 74 ++++++++ server/mcp-tools/rswag.ts | 64 +++++++ server/mcp-tools/rtasks.ts | 236 +++++++++++++++++++++++++ server/mcp-tools/rtime.ts | 176 ++++++++++++++++++ server/mcp-tools/rtrips.ts | 156 ++++++++++++++++ server/mcp-tools/rtube.ts | 59 +++++++ server/mcp-tools/rvnb.ts | 104 +++++++++++ server/mcp-tools/rvote.ts | 132 ++++++++++++++ server/mcp-tools/rwallet.ts | 177 +++++++++++++++++++ server/mcp-tools/spaces.ts | 98 +++++++++++ server/mi-data-queries.ts | 323 ++++++++++++++++++++++++++++++++++ server/mi-routes.ts | 297 ++++++++++++++++++++++++++++++- 72 files changed, 6219 insertions(+), 1 deletion(-) create mode 100644 server/mcp-server.ts create mode 100644 server/mcp-tools/_auth.ts create mode 100644 server/mcp-tools/crowdsurf.ts create mode 100644 server/mcp-tools/rbnb.ts create mode 100644 server/mcp-tools/rbooks.ts create mode 100644 server/mcp-tools/rcal.ts create mode 100644 server/mcp-tools/rcart.ts create mode 100644 server/mcp-tools/rchats.ts create mode 100644 server/mcp-tools/rchoices.ts create mode 100644 server/mcp-tools/rdata.ts create mode 100644 server/mcp-tools/rdesign.ts create mode 100644 server/mcp-tools/rdocs.ts create mode 100644 server/mcp-tools/rexchange.ts create mode 100644 server/mcp-tools/rfiles.ts create mode 100644 server/mcp-tools/rflows.ts create mode 100644 server/mcp-tools/rforum.ts create mode 100644 server/mcp-tools/rgov.ts create mode 100644 server/mcp-tools/rinbox.ts create mode 100644 server/mcp-tools/rmaps.ts create mode 100644 server/mcp-tools/rmeets.ts create mode 100644 server/mcp-tools/rnetwork.ts create mode 100644 server/mcp-tools/rnotes.ts create mode 100644 server/mcp-tools/rphotos.ts create mode 100644 server/mcp-tools/rpubs.ts create mode 100644 server/mcp-tools/rschedule.ts create mode 100644 server/mcp-tools/rsheet.ts create mode 100644 server/mcp-tools/rsocials.ts create mode 100644 server/mcp-tools/rsplat.ts create mode 100644 server/mcp-tools/rswag.ts create mode 100644 server/mcp-tools/rtasks.ts create mode 100644 server/mcp-tools/rtime.ts create mode 100644 server/mcp-tools/rtrips.ts create mode 100644 server/mcp-tools/rtube.ts create mode 100644 server/mcp-tools/rvnb.ts create mode 100644 server/mcp-tools/rvote.ts create mode 100644 server/mcp-tools/rwallet.ts create mode 100644 server/mcp-tools/spaces.ts diff --git a/modules/crowdsurf/mod.ts b/modules/crowdsurf/mod.ts index 629064fa..d1e98293 100644 --- a/modules/crowdsurf/mod.ts +++ b/modules/crowdsurf/mod.ts @@ -191,6 +191,28 @@ function seedTemplateCrowdSurf(space: string) { console.log(`[CrowdSurf] Template seeded for "${space}": 3 prompt shapes`); } +// ── MI export ── + +export function getActivePromptsForMI(space: string, limit = 5): { id: string; text: string; swipeCount: number; threshold: number; triggered: boolean; createdAt: number }[] { + const docData = getDocumentData(space); + if (!docData?.shapes) return []; + const results: { id: string; text: string; swipeCount: number; threshold: number; triggered: boolean; createdAt: number }[] = []; + for (const [id, shape] of Object.entries(docData.shapes as Record)) { + if (shape.forgotten || shape.type !== "folk-crowdsurf-prompt") continue; + results.push({ + id, + text: shape.text || "Untitled", + swipeCount: Object.keys(shape.swipes || {}).length, + threshold: shape.threshold || 3, + triggered: shape.triggered || false, + createdAt: shape.createdAt, + }); + } + return results + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); +} + export const crowdsurfModule: RSpaceModule = { id: "crowdsurf", name: "CrowdSurf", diff --git a/modules/rbnb/mod.ts b/modules/rbnb/mod.ts index c74988f7..10df0eb1 100644 --- a/modules/rbnb/mod.ts +++ b/modules/rbnb/mod.ts @@ -1207,6 +1207,18 @@ routes.get("/", (c) => { })); }); +export function getActiveListingsForMI(space: string, limit = 5): { id: string; title: string; type: string; locationName: string; economy: string; createdAt: number }[] { + if (!_syncServer) return []; + const docId = bnbDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.listings) + .filter((l) => l.isActive) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((l) => ({ id: l.id, title: l.title, type: l.type, locationName: l.locationName || "", economy: l.economy, createdAt: l.createdAt })); +} + // ── Module export ── export const bnbModule: RSpaceModule = { diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index f322d8ae..9eb3e87f 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -422,6 +422,17 @@ function seedTemplateBooks(space: string) { // ── Module export ── +export function getRecentBooksForMI(space: string, limit = 5): { id: string; title: string; author: string; pageCount: number; createdAt: number }[] { + if (!_syncServer) return []; + const docId = booksCatalogDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.items) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((b) => ({ id: b.id, title: b.title, author: b.author, pageCount: b.pageCount || 0, createdAt: b.createdAt })); +} + export const booksModule: RSpaceModule = { id: "rbooks", name: "rBooks", diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index a1e0d639..fc39fdd8 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -2672,6 +2672,19 @@ function seedTemplateCart(space: string) { console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`); } +export function getRecentOrdersForMI(space: string, limit = 5): { id: string; title: string; status: string; totalPrice: number; createdAt: number }[] { + if (!_syncServer) return []; + const items: { id: string; title: string; status: string; totalPrice: number; createdAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(`${space}:cart:orders:`)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.order) continue; + const o = doc.order; + items.push({ id: o.id, title: o.catalogEntryId, status: o.status, totalPrice: o.totalPrice || 0, createdAt: o.createdAt }); + } + return items.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} + export const cartModule: RSpaceModule = { id: "rcart", name: "rCart", diff --git a/modules/rchats/mod.ts b/modules/rchats/mod.ts index 3ed4dc42..ad718ecd 100644 --- a/modules/rchats/mod.ts +++ b/modules/rchats/mod.ts @@ -6,13 +6,124 @@ */ import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas'; +import type { ChatsDirectoryDoc, ChatChannelDoc, ChannelInfo, ChatMessage } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); +// ── Local-first helpers ── + +function ensureDirectoryDoc(space: string): ChatsDirectoryDoc { + const docId = chatsDirectoryDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init chats directory', (d) => { + const init = chatsDirectorySchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { + const docId = chatChannelDocId(space, channelId); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init channel', (d) => { + const init = chatChannelSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.channelId = channelId; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── CRUD: Channels ── + +routes.get("/api/channels", (c) => { + if (!_syncServer) return c.json({ channels: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureDirectoryDoc(space); + return c.json({ channels: Object.values(doc.channels || {}) }); +}); + +routes.post("/api/channels", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name, description = "", isPrivate = false } = await c.req.json(); + if (!name) return c.json({ error: "name required" }, 400); + const id = crypto.randomUUID(); + const docId = chatsDirectoryDocId(space); + ensureDirectoryDoc(space); + _syncServer.changeDoc(docId, `create channel ${id}`, (d) => { + d.channels[id] = { id, name, description, isPrivate, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.channels[id], 201); +}); + +// ── CRUD: Messages ── + +routes.get("/api/channels/:channelId/messages", (c) => { + if (!_syncServer) return c.json({ messages: [] }); + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const doc = ensureChannelDoc(space, channelId); + const messages = Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt); + return c.json({ messages }); +}); + +routes.post("/api/channels/:channelId/messages", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const { content, replyTo = null } = await c.req.json(); + if (!content) return c.json({ error: "content required" }, 400); + const id = crypto.randomUUID(); + const docId = chatChannelDocId(space, channelId); + ensureChannelDoc(space, channelId); + _syncServer.changeDoc(docId, `add message ${id}`, (d) => { + d.messages[id] = { id, channelId, authorId: claims.sub || '', authorName: (claims.displayName as string) || claims.username || 'Anonymous', content, replyTo, editedAt: null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.messages[id], 201); +}); + +routes.delete("/api/channels/:channelId/messages/:msgId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const msgId = c.req.param("msgId"); + const docId = chatChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.messages[msgId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; }); + return c.json({ ok: true }); +}); + // ── Hub page (Coming Soon dashboard) ── routes.get("/", (c) => { @@ -68,6 +179,22 @@ routes.get("/", (c) => { })); }); +// ── MI Integration ── + +export function getRecentMessagesForMI(space: string, limit = 5): { id: string; channel: string; author: string; content: string; createdAt: number }[] { + if (!_syncServer) return []; + const all: { id: string; channel: string; author: string; content: string; createdAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(`${space}:chats:channel:`)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.messages) continue; + for (const msg of Object.values(doc.messages)) { + all.push({ id: msg.id, channel: msg.channelId, author: msg.authorName, content: msg.content.slice(0, 200), createdAt: msg.createdAt }); + } + } + return all.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} + // ── Module export ── export const chatsModule: RSpaceModule = { @@ -76,6 +203,11 @@ export const chatsModule: RSpaceModule = { icon: "🗨️", description: "Encrypted community messaging", scoping: { defaultScope: "space", userConfigurable: false }, + docSchemas: [ + { pattern: '{space}:chats:channels', description: 'Channel directory per space', init: chatsDirectorySchema.init }, + { pattern: '{space}:chats:channel:{channelId}', description: 'Messages per channel', init: chatChannelSchema.init }, + ], routes, landingPage: renderLanding, + async onInit(ctx) { _syncServer = ctx.syncServer; }, }; diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 79fbc912..32a7d451 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -171,6 +171,17 @@ function seedTemplateChoices(space: string) { console.log(`[Choices] Template seeded for "${space}": 3 choice shapes`); } +// ── MI export ── + +export function getRecentChoiceSessionsForMI(space: string, limit = 5): { id: string; title: string; type: string; optionCount: number; closed: boolean; createdAt: number }[] { + const doc = syncServer.getDoc(choicesDocId(space)); + if (!doc) return []; + return Object.values(doc.sessions) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((s) => ({ id: s.id, title: s.title, type: s.type, optionCount: s.options?.length || 0, closed: !!s.closed, createdAt: s.createdAt })); +} + export const choicesModule: RSpaceModule = { id: "rchoices", name: "rChoices", diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index a81c3b03..b7fa4fcc 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -287,6 +287,12 @@ routes.get("/:tabId", (c, next) => { return c.html(renderDataPage(space, tabId, c.get("isSubdomain"))); }); +// ── MI export ── + +export function getDataSummaryForMI(_space: string, _limit = 5): { label: string; value: string }[] { + return []; // rData proxies Umami analytics — no local data to summarize +} + export const dataModule: RSpaceModule = { id: "rdata", name: "rData", diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index c2f8e81a..7851faf5 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -11,6 +11,27 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { designAgentRoutes } from "./design-agent-route"; import { ensureSidecar } from "../../server/sidecar-manager"; +import * as Automerge from "@automerge/automerge"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { designSchema, designDocId } from './schemas'; +import type { DesignDoc } from './schemas'; + +let _syncServer: SyncServer | null = null; + +function ensureDesignDoc(space: string): DesignDoc { + const docId = designDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init design', (d) => { + const init = designSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} const routes = new Hono(); @@ -47,6 +68,20 @@ routes.all("/api/bridge/*", async (c) => { } }); +// ── CRUD: Design sessions ── + +routes.get("/api/sessions", (c) => { + if (!_syncServer) return c.json({ sessions: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureDesignDoc(space); + const frames = Object.values(doc.document?.frames || {}); + return c.json({ frameCount: frames.length, title: doc.document?.title || 'Untitled' }); +}); + +routes.get("/api/templates", (c) => { + return c.json({ templates: [] }); +}); + routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); @@ -670,6 +705,13 @@ function renderDesignLanding(): string { `; } +export function getRecentSessionsForMI(space: string, limit = 5): { title: string; frameCount: number; pageCount: number }[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(designDocId(space)); + if (!doc?.document) return []; + return [{ title: doc.document.title, frameCount: Object.keys(doc.document.frames || {}).length, pageCount: Object.keys(doc.document.pages || {}).length }]; +} + export const designModule: RSpaceModule = { id: "rdesign", name: "rDesign", @@ -680,6 +722,8 @@ export const designModule: RSpaceModule = { scoping: { defaultScope: 'global', userConfigurable: false }, publicWrite: true, routes, + docSchemas: [{ pattern: '{space}:design:doc', description: 'Design document per space', init: designSchema.init }], + async onInit(ctx) { _syncServer = ctx.syncServer; }, landingPage: renderDesignLanding, feeds: [ { id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, layouts, and print-ready exports" }, diff --git a/modules/rdocs/mod.ts b/modules/rdocs/mod.ts index 42e78cbd..f1211a43 100644 --- a/modules/rdocs/mod.ts +++ b/modules/rdocs/mod.ts @@ -5,14 +5,79 @@ */ import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { docsSchema, docsDocId } from './schemas'; +import type { DocsDoc, LinkedDocument } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); const DOCMOST_URL = "https://docs.cosmolocal.world"; +// ── Local-first helpers ── + +function ensureDocsDoc(space: string): DocsDoc { + const docId = docsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init docs registry', (d) => { + const init = docsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── CRUD: Document Registry ── + +routes.get("/api/registry", (c) => { + if (!_syncServer) return c.json({ documents: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureDocsDoc(space); + return c.json({ documents: Object.values(doc.linkedDocuments || {}) }); +}); + +routes.post("/api/registry", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { url, title } = await c.req.json(); + if (!url || !title) return c.json({ error: "url and title required" }, 400); + const id = crypto.randomUUID(); + const docId = docsDocId(space); + ensureDocsDoc(space); + _syncServer.changeDoc(docId, `register document ${id}`, (d) => { + d.linkedDocuments[id] = { id, url, title, addedBy: claims.sub || null, addedAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.linkedDocuments[id], 201); +}); + +routes.delete("/api/registry/:docRefId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const docRefId = c.req.param("docRefId"); + const docId = docsDocId(space); + const doc = ensureDocsDoc(space); + if (!doc.linkedDocuments[docRefId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `unregister document ${docRefId}`, (d) => { delete d.linkedDocuments[docRefId]; }); + return c.json({ ok: true }); +}); + routes.get("/api/health", (c) => { return c.json({ ok: true, module: "rdocs" }); }); @@ -58,15 +123,30 @@ function renderDocsLanding(): string { `; } +// ── MI Integration ── + +export function getLinkedDocsForMI(space: string, limit = 5): { id: string; title: string; url: string; addedAt: number }[] { + if (!_syncServer) return []; + const docId = docsDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.linkedDocuments) + .sort((a, b) => b.addedAt - a.addedAt) + .slice(0, limit) + .map((d) => ({ id: d.id, title: d.title, url: d.url, addedAt: d.addedAt })); +} + export const docsModule: RSpaceModule = { id: "rdocs", name: "rDocs", icon: "📝", description: "Collaborative documentation and knowledge base", scoping: { defaultScope: 'global', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:docs:links', description: 'Linked Docmost documents per space', init: docsSchema.init }], routes, landingPage: renderDocsLanding, externalApp: { url: DOCMOST_URL, name: "Docmost" }, + async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "documents", name: "Documents", kind: "data", description: "Collaborative documents and wiki pages" }, ], diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts index 78a58e80..96a840c5 100644 --- a/modules/rexchange/mod.ts +++ b/modules/rexchange/mod.ts @@ -483,6 +483,17 @@ routes.get('/', (c) => { // ── Module export ── +export function getRecentIntentsForMI(space: string, limit = 5): { id: string; side: string; tokenId: string; status: string; createdAt: number }[] { + if (!_syncServer) return []; + const docId = exchangeIntentsDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.intents) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((i) => ({ id: i.id, side: i.side, tokenId: i.tokenId, status: i.status, createdAt: i.createdAt })); +} + export const exchangeModule: RSpaceModule = { id: 'rexchange', name: 'rExchange', diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index b841b731..b348b327 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -707,3 +707,44 @@ export const filesModule: RSpaceModule = { { label: "Upload Files", icon: "⬆️", description: "Add files to your space", type: 'create', href: '/{space}/rfiles' }, ], }; + +// ── MI Data Export ── + +const FILES_PREFIX_MI = ":files:cards:"; + +export interface MIFileItem { + id: string; + title: string | null; + originalFilename: string; + mimeType: string | null; + fileSize: number; + tags: string[]; + updatedAt: number; +} + +export function getRecentFilesForMI(space: string, limit = 5): MIFileItem[] { + if (!_syncServer) return []; + const prefix = `${space}${FILES_PREFIX_MI}`; + const all: MIFileItem[] = []; + + for (const docId of _syncServer.getDocIds()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.files) continue; + for (const f of Object.values(doc.files)) { + all.push({ + id: f.id, + title: f.title, + originalFilename: f.originalFilename, + mimeType: f.mimeType, + fileSize: f.fileSize, + tags: f.tags ? Array.from(f.tags) : [], + updatedAt: f.updatedAt, + }); + } + } + + return all + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit); +} diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 52434f39..c64f900d 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -967,6 +967,17 @@ function seedTemplateFlows(space: string) { } } +export function getRecentFlowsForMI(space: string, limit = 5): { id: string; name: string; nodeCount: number; createdAt: number }[] { + if (!_syncServer) return []; + const docId = flowsDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.canvasFlows) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((f) => ({ id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, createdAt: f.createdAt })); +} + export const flowsModule: RSpaceModule = { id: "rflows", name: "rFlows", diff --git a/modules/rforum/mod.ts b/modules/rforum/mod.ts index 585d2248..aa4fb385 100644 --- a/modules/rforum/mod.ts +++ b/modules/rforum/mod.ts @@ -224,6 +224,19 @@ function seedTemplateForum(_space: string) { console.log(`[Forum] Template seeded: 1 demo instance`); } +// ── MI export ── + +export function getForumInstancesForMI(_space: string, limit = 5): { id: string; name: string; domain: string; status: string; createdAt: number }[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(FORUM_DOC_ID); + if (!doc) return []; + return Object.values(doc.instances) + .filter((i) => i.status !== "destroyed") + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((i) => ({ id: i.id, name: i.name, domain: i.domain || "", status: i.status, createdAt: i.createdAt })); +} + export const forumModule: RSpaceModule = { id: "rforum", name: "rForum", diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 1f8491a7..a5c9848a 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -261,6 +261,22 @@ function seedTemplateGov(space: string) { console.log(`[rGov] Template seeded for "${space}": 3 circuits (13 shapes + 9 arrows)`); } +// ── MI export ── + +export function getGovShapesForMI(space: string, _limit = 5): { type: string; count: number }[] { + const doc = getDocumentData(space); + if (!doc?.shapes) return []; + const govTypes = ['folk-gov-binary', 'folk-gov-threshold', 'folk-gov-knob', 'folk-gov-project', 'folk-gov-amendment', 'folk-gov-quadratic', 'folk-gov-conviction', 'folk-gov-multisig']; + const counts: Record = {}; + for (const shape of Object.values(doc.shapes as Record)) { + const tag = shape.type; + if (govTypes.includes(tag)) { + counts[tag] = (counts[tag] || 0) + 1; + } + } + return Object.entries(counts).map(([type, count]) => ({ type, count })); +} + // ── Module export ── export const govModule: RSpaceModule = { diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index c43729a3..b850a866 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -2422,3 +2422,40 @@ export const inboxModule: RSpaceModule = { { label: "Create a Mailbox", icon: "📬", description: "Set up a shared team mailbox", type: 'create', href: '/{space}/rinbox' }, ], }; + +// ── MI Data Export ── + +const INBOX_PREFIX = ":inbox:mailboxes:"; + +export interface MIThreadItem { + subject: string; + fromAddress: string | null; + status: string; + isRead: boolean; + receivedAt: number; +} + +export function getRecentThreadsForMI(space: string, limit = 5): MIThreadItem[] { + if (!_syncServer) return []; + const prefix = `${space}${INBOX_PREFIX}`; + const all: MIThreadItem[] = []; + + for (const docId of _syncServer.getDocIds()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.threads) continue; + for (const t of Object.values(doc.threads)) { + all.push({ + subject: t.subject, + fromAddress: t.fromAddress, + status: t.status, + isRead: t.isRead, + receivedAt: t.createdAt, + }); + } + } + + return all + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, limit); +} diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index 82f5907e..5afbe1d1 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -7,15 +7,164 @@ */ import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { mapsSchema, mapsDocId } from './schemas'; +import type { MapsDoc, MapAnnotation, SavedRoute, SavedMeetingPoint } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; +// ── Local-first helpers ── + +function ensureMapsDoc(space: string): MapsDoc { + const docId = mapsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init maps', (d) => { + const init = mapsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── CRUD: Pins (annotations) ── + +routes.get("/api/pins", (c) => { + if (!_syncServer) return c.json({ pins: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureMapsDoc(space); + return c.json({ pins: Object.values(doc.annotations || {}) }); +}); + +routes.post("/api/pins", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { type = 'pin', lat, lng, label = '' } = await c.req.json(); + if (lat == null || lng == null) return c.json({ error: "lat and lng required" }, 400); + const id = crypto.randomUUID(); + const docId = mapsDocId(space); + ensureMapsDoc(space); + _syncServer.changeDoc(docId, `add pin ${id}`, (d) => { + d.annotations[id] = { id, type: type as 'pin' | 'note' | 'area', lat, lng, label, authorDid: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.annotations[id], 201); +}); + +routes.delete("/api/pins/:pinId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const pinId = c.req.param("pinId"); + const docId = mapsDocId(space); + const doc = ensureMapsDoc(space); + if (!doc.annotations[pinId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete pin ${pinId}`, (d) => { delete d.annotations[pinId]; }); + return c.json({ ok: true }); +}); + +// ── CRUD: Saved Routes ── + +routes.get("/api/saved-routes", (c) => { + if (!_syncServer) return c.json({ routes: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureMapsDoc(space); + return c.json({ routes: Object.values(doc.savedRoutes || {}) }); +}); + +routes.post("/api/saved-routes", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name, waypoints } = await c.req.json(); + if (!name || !Array.isArray(waypoints) || waypoints.length < 2) return c.json({ error: "name and waypoints (min 2) required" }, 400); + const id = crypto.randomUUID(); + const docId = mapsDocId(space); + ensureMapsDoc(space); + _syncServer.changeDoc(docId, `save route ${id}`, (d) => { + d.savedRoutes[id] = { id, name, waypoints, authorDid: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.savedRoutes[id], 201); +}); + +routes.delete("/api/saved-routes/:routeId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const routeId = c.req.param("routeId"); + const docId = mapsDocId(space); + const doc = ensureMapsDoc(space); + if (!doc.savedRoutes[routeId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete route ${routeId}`, (d) => { delete d.savedRoutes[routeId]; }); + return c.json({ ok: true }); +}); + +// ── CRUD: Meeting Points ── + +routes.get("/api/meeting-points", (c) => { + if (!_syncServer) return c.json({ meetingPoints: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureMapsDoc(space); + return c.json({ meetingPoints: Object.values(doc.savedMeetingPoints || {}) }); +}); + +routes.post("/api/meeting-points", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name, lat, lng } = await c.req.json(); + if (!name || lat == null || lng == null) return c.json({ error: "name, lat, and lng required" }, 400); + const id = crypto.randomUUID(); + const docId = mapsDocId(space); + ensureMapsDoc(space); + _syncServer.changeDoc(docId, `save meeting point ${id}`, (d) => { + d.savedMeetingPoints[id] = { id, name, lat, lng, setBy: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.savedMeetingPoints[id], 201); +}); + +routes.delete("/api/meeting-points/:pointId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const pointId = c.req.param("pointId"); + const docId = mapsDocId(space); + const doc = ensureMapsDoc(space); + if (!doc.savedMeetingPoints[pointId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete meeting point ${pointId}`, (d) => { delete d.savedMeetingPoints[pointId]; }); + return c.json({ ok: true }); +}); + // ── Sync URL for client-side WebSocket connection ── routes.get("/api/sync-url", (c) => { const wsUrl = process.env.MAPS_SYNC_URL || "wss://maps-sync.rspace.online"; @@ -299,6 +448,19 @@ routes.get("/:room", (c) => { })); }); +// ── MI Integration ── + +export function getMapPinsForMI(space: string, limit = 10): { id: string; type: string; lat: number; lng: number; label: string; createdAt: number }[] { + if (!_syncServer) return []; + const docId = mapsDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.annotations) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((a) => ({ id: a.id, type: a.type, lat: a.lat, lng: a.lng, label: a.label, createdAt: a.createdAt })); +} + export const mapsModule: RSpaceModule = { id: "rmaps", name: "rMaps", @@ -307,9 +469,11 @@ export const mapsModule: RSpaceModule = { canvasShapes: ["folk-map"], canvasToolIds: ["create_map"], scoping: { defaultScope: 'global', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:maps:annotations', description: 'Map annotations, routes, and meeting points', init: mapsSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rmaps.online", + async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "locations", diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index 6971b7f2..61dabac7 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -11,6 +11,27 @@ import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/sh import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; +import * as Automerge from "@automerge/automerge"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { meetsSchema, meetsDocId } from './schemas'; +import type { MeetsDoc, Meeting } from './schemas'; + +let _syncServer: SyncServer | null = null; + +function ensureMeetsDoc(space: string): MeetsDoc { + const docId = meetsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init meets', (d) => { + const init = meetsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} const JITSI_URL = process.env.JITSI_URL || "https://jeffsi.localvibe.live"; const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000"; @@ -99,6 +120,48 @@ const MI_STYLES = ``; +// ── CRUD: Meetings ── + +routes.get("/api/meetings", (c) => { + if (!_syncServer) return c.json({ meetings: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureMeetsDoc(space); + const meetings = Object.values(doc.meetings || {}).sort((a, b) => b.createdAt - a.createdAt); + return c.json({ meetings }); +}); + +routes.post("/api/meetings", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { title, roomName, scheduledAt, participants = [] } = await c.req.json(); + if (!title) return c.json({ error: "title required" }, 400); + const id = crypto.randomUUID(); + const docId = meetsDocId(space); + ensureMeetsDoc(space); + _syncServer.changeDoc(docId, `create meeting ${id}`, (d) => { + d.meetings[id] = { id, roomName: roomName || id, title, scheduledAt: scheduledAt || Date.now(), hostDid: null, participants, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.meetings[id], 201); +}); + +routes.delete("/api/meetings/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + const docId = meetsDocId(space); + const doc = ensureMeetsDoc(space); + if (!doc.meetings[id]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete meeting ${id}`, (d) => { delete d.meetings[id]; }); + return c.json({ ok: true }); +}); + // ── Direct Jitsi lobby ── routes.get("/meet", (c) => { @@ -568,6 +631,17 @@ function formatTimestamp(seconds: number): string { return `${m}:${String(s).padStart(2, "0")}`; } +export function getRecentMeetingsForMI(space: string, limit = 5): { id: string; title: string; roomName: string; scheduledAt: number; participantCount: number; createdAt: number }[] { + if (!_syncServer) return []; + const docId = meetsDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc?.meetings) return []; + return Object.values(doc.meetings) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map(m => ({ id: m.id, title: m.title, roomName: m.roomName, scheduledAt: m.scheduledAt, participantCount: m.participants.length, createdAt: m.createdAt })); +} + // ── Module export ── export const meetsModule: RSpaceModule = { @@ -577,6 +651,8 @@ export const meetsModule: RSpaceModule = { description: "Video meetings powered by Jitsi", scoping: { defaultScope: "space", userConfigurable: false }, routes, + docSchemas: [{ pattern: '{space}:meets:meetings', description: 'Meeting scheduling per space', init: meetsSchema.init }], + async onInit(ctx) { _syncServer = ctx.syncServer; }, landingPage: renderLanding, externalApp: { url: JITSI_URL, name: "Jitsi Meet" }, outputPaths: [ diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 3a80ca44..0bdb82da 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -738,6 +738,32 @@ routes.get("/", (c) => { })); }); +// ── MI Data Export ── +// rNetwork doesn't have its own _syncServer; use the singleton. +import { syncServer as _syncServerSingleton } from "../../server/sync-instance"; +import { networkDocId, type NetworkDoc } from "./schemas"; + +export interface MIContactItem { + did: string; + name: string; + role: string; + tags: string[]; +} + +export function getRecentContactsForMI(space: string, limit = 5): MIContactItem[] { + const doc = _syncServerSingleton?.getDoc(networkDocId(space)); + if (!doc?.contacts) return []; + + return Object.values(doc.contacts) + .slice(0, limit) + .map(c => ({ + did: c.did, + name: c.name, + role: c.role, + tags: c.tags ? Array.from(c.tags) : [], + })); +} + export const networkModule: RSpaceModule = { id: "rnetwork", name: "rNetwork", diff --git a/modules/rphotos/mod.ts b/modules/rphotos/mod.ts index 8fd8c3e0..bb066c82 100644 --- a/modules/rphotos/mod.ts +++ b/modules/rphotos/mod.ts @@ -7,14 +7,121 @@ */ import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { photosSchema, photosDocId } from './schemas'; +import type { PhotosDoc, SharedAlbum, PhotoAnnotation } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284"; + +// ── Local-first helpers ── + +function ensurePhotosDoc(space: string): PhotosDoc { + const docId = photosDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init photos', (d) => { + const init = photosSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── CRUD: Curated Albums ── + +routes.get("/api/curations", (c) => { + if (!_syncServer) return c.json({ albums: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensurePhotosDoc(space); + return c.json({ albums: Object.values(doc.sharedAlbums || {}) }); +}); + +routes.post("/api/curations", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name, description = "" } = await c.req.json(); + if (!name) return c.json({ error: "name required" }, 400); + const id = crypto.randomUUID(); + const docId = photosDocId(space); + ensurePhotosDoc(space); + _syncServer.changeDoc(docId, `share album ${id}`, (d) => { + d.sharedAlbums[id] = { id, name, description, sharedBy: claims.sub || null, sharedAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.sharedAlbums[id], 201); +}); + +routes.delete("/api/curations/:albumId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const albumId = c.req.param("albumId"); + const docId = photosDocId(space); + const doc = ensurePhotosDoc(space); + if (!doc.sharedAlbums[albumId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `remove album ${albumId}`, (d) => { delete d.sharedAlbums[albumId]; }); + return c.json({ ok: true }); +}); + +// ── CRUD: Photo Annotations ── + +routes.get("/api/annotations", (c) => { + if (!_syncServer) return c.json({ annotations: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensurePhotosDoc(space); + return c.json({ annotations: Object.values(doc.annotations || {}) }); +}); + +routes.post("/api/annotations", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { assetId, note } = await c.req.json(); + if (!assetId || !note) return c.json({ error: "assetId and note required" }, 400); + const id = crypto.randomUUID(); + const docId = photosDocId(space); + ensurePhotosDoc(space); + _syncServer.changeDoc(docId, `annotate ${assetId}`, (d) => { + d.annotations[id] = { assetId, note, authorDid: (claims.did as string) || claims.sub || '', createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.annotations[id], 201); +}); + +routes.delete("/api/annotations/:annotationId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const annotationId = c.req.param("annotationId"); + const docId = photosDocId(space); + const doc = ensurePhotosDoc(space); + if (!doc.annotations[annotationId]) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete annotation ${annotationId}`, (d) => { delete d.annotations[annotationId]; }); + return c.json({ ok: true }); +}); const IMMICH_API_KEY = process.env.RPHOTOS_API_KEY || ""; const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online"; @@ -138,16 +245,31 @@ routes.get("/", (c) => { })); }); +// ── MI Integration ── + +export function getSharedAlbumsForMI(space: string, limit = 5): { id: string; name: string; sharedAt: number }[] { + if (!_syncServer) return []; + const docId = photosDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.sharedAlbums) + .sort((a, b) => b.sharedAt - a.sharedAt) + .slice(0, limit) + .map((a) => ({ id: a.id, name: a.name, sharedAt: a.sharedAt })); +} + export const photosModule: RSpaceModule = { id: "rphotos", name: "rPhotos", icon: "📸", description: "Community photo commons", scoping: { defaultScope: 'global', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:photos:albums', description: 'Shared albums and annotations per space', init: photosSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rphotos.online", externalApp: { url: IMMICH_PUBLIC_URL, name: "Immich" }, + async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "rphotos", diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index f00627dd..b470e625 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -20,6 +20,11 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; +import * as Automerge from "@automerge/automerge"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { pubsDraftSchema, pubsDocId } from './schemas'; +import type { PubsDoc } from './schemas'; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts"; @@ -181,6 +186,8 @@ function escapeAttr(s: string): string { // ── Routes ── +let _syncServer: SyncServer | null = null; + const routes = new Hono(); // ── API: List available formats ── @@ -655,6 +662,65 @@ routes.get("/api/batch", async (c) => { } }); +// ── CRUD: Drafts (Automerge) ── + +routes.get("/api/drafts", (c) => { + if (!_syncServer) return c.json({ drafts: [] }); + const space = c.req.param("space") || "demo"; + const prefix = `${space}:pubs:drafts:`; + const drafts: any[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.draft) continue; + drafts.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, createdAt: doc.draft.createdAt, updatedAt: doc.draft.updatedAt }); + } + return c.json({ drafts: drafts.sort((a, b) => b.updatedAt - a.updatedAt) }); +}); + +routes.post("/api/drafts", async (c) => { + const authToken = extractToken(c.req.raw.headers); + if (!authToken) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { title = "Untitled", author = "", format = "digest", content = "" } = await c.req.json(); + const id = crypto.randomUUID(); + const docId = pubsDocId(space, id); + const now = Date.now(); + const doc = Automerge.change(Automerge.init(), 'create draft', (d) => { + const init = pubsDraftSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.draft.id = id; + d.draft.title = title; + d.draft.author = author; + d.draft.format = format; + d.draft.createdAt = now; + d.draft.updatedAt = now; + d.content = content; + }); + _syncServer.setDoc(docId, doc); + return c.json({ id, title, author, format }, 201); +}); + +routes.delete("/api/drafts/:id", async (c) => { + const authToken = extractToken(c.req.raw.headers); + if (!authToken) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + const docId = pubsDocId(space, id); + const doc = _syncServer.getDoc(docId); + if (!doc) return c.json({ error: "Not found" }, 404); + _syncServer.changeDoc(docId, `delete draft ${id}`, (d) => { + d.draft.title = '[deleted]'; + d.content = ''; + }); + return c.json({ ok: true }); +}); + // ── Page: Zine Generator (redirect to canvas with auto-spawn) ── routes.get("/zine", (c) => { const spaceSlug = c.req.param("space") || "personal"; @@ -696,6 +762,19 @@ routes.get("/", (c) => { })); }); +export function getRecentPublicationsForMI(space: string, limit = 5): { id: string; title: string; author: string; format: string; updatedAt: number }[] { + if (!_syncServer) return []; + const prefix = `${space}:pubs:drafts:`; + const items: { id: string; title: string; author: string; format: string; updatedAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.draft || doc.draft.title === '[deleted]') continue; + items.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, updatedAt: doc.draft.updatedAt }); + } + return items.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit); +} + // ── Module export ── export const pubsModule: RSpaceModule = { @@ -705,6 +784,8 @@ export const pubsModule: RSpaceModule = { description: "Drop in a document, get a pocket book", scoping: { defaultScope: 'global', userConfigurable: true }, routes, + docSchemas: [{ pattern: '{space}:pubs:drafts:{draftId}', description: 'One doc per publication draft', init: pubsDraftSchema.init }], + async onInit(ctx) { _syncServer = ctx.syncServer; }, publicWrite: true, standaloneDomain: "rpubs.online", landingPage: renderLanding, diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 1c895c6e..6d53f580 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -2108,3 +2108,34 @@ export const scheduleModule: RSpaceModule = { { label: "Create a Schedule", icon: "⏱", description: "Set up a recurring job or reminder", type: 'create', href: '/{space}/rschedule' }, ], }; + +// ── MI Data Export ── + +export interface MIReminderItem { + id: string; + title: string; + remindAt: number; + sourceModule: string | null; + sourceLabel: string | null; +} + +export function getUpcomingRemindersForMI(space: string, days = 14, limit = 5): MIReminderItem[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(scheduleDocId(space)); + if (!doc?.reminders) return []; + + const now = Date.now(); + const cutoff = now + days * 86400000; + + return Object.values(doc.reminders) + .filter(r => !r.completed && r.remindAt >= now && r.remindAt <= cutoff) + .sort((a, b) => a.remindAt - b.remindAt) + .slice(0, limit) + .map(r => ({ + id: r.id, + title: r.title, + remindAt: r.remindAt, + sourceModule: r.sourceModule, + sourceLabel: r.sourceLabel, + })); +} diff --git a/modules/rsheet/mod.ts b/modules/rsheet/mod.ts index ddfab6a7..8f34fd6d 100644 --- a/modules/rsheet/mod.ts +++ b/modules/rsheet/mod.ts @@ -5,12 +5,103 @@ */ import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; import { renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { sheetSchema, sheetDocId } from './schemas'; +import type { SheetDoc } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); +// ── Local-first helpers ── + +function ensureSheetDoc(space: string, sheetId: string): SheetDoc { + const docId = sheetDocId(space, sheetId); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init sheet', (d) => { + const init = sheetSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.sheet.id = sheetId; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── CRUD: Sheets ── + +routes.get("/api/sheets", (c) => { + if (!_syncServer) return c.json({ sheets: [] }); + const space = c.req.param("space") || "demo"; + const prefix = `${space}:sheet:sheets:`; + const sheets: any[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.sheet) continue; + sheets.push({ id: doc.sheet.id, name: doc.sheet.name, description: doc.sheet.description, cellCount: Object.keys(doc.cells || {}).length, createdAt: doc.sheet.createdAt, updatedAt: doc.sheet.updatedAt }); + } + return c.json({ sheets }); +}); + +routes.post("/api/sheets", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name = "Untitled Sheet", description = "" } = await c.req.json(); + const id = crypto.randomUUID(); + const docId = sheetDocId(space, id); + const doc = Automerge.change(Automerge.init(), 'create sheet', (d) => { + const init = sheetSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.sheet.id = id; + d.sheet.name = name; + d.sheet.description = description; + }); + _syncServer.setDoc(docId, doc); + const created = _syncServer.getDoc(docId)!; + return c.json({ id: created.sheet.id, name: created.sheet.name }, 201); +}); + +routes.get("/api/sheets/:id", (c) => { + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + const doc = _syncServer.getDoc(sheetDocId(space, id)); + if (!doc) return c.json({ error: "Not found" }, 404); + return c.json({ sheet: doc.sheet, cells: doc.cells, columns: doc.columns, rows: doc.rows }); +}); + +routes.put("/api/sheets/:id/cells", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + const docId = sheetDocId(space, id); + const doc = _syncServer.getDoc(docId); + if (!doc) return c.json({ error: "Not found" }, 404); + const { cells } = await c.req.json(); + if (!cells || typeof cells !== 'object') return c.json({ error: "cells object required" }, 400); + _syncServer.changeDoc(docId, 'update cells', (d) => { + for (const [key, val] of Object.entries(cells as Record)) { + d.cells[key] = { value: val.value || '', formula: val.formula || null, format: val.format || null, updatedAt: Date.now() }; + } + }); + return c.json({ ok: true }); +}); + // ── Routes ── routes.get("/", (c) => { @@ -157,6 +248,21 @@ routes.get("/app", (c) => { `); }); +// ── MI Integration ── + +export function getRecentSheetsForMI(space: string, limit = 5): { id: string; name: string; cellCount: number; updatedAt: number }[] { + if (!_syncServer) return []; + const sheets: { id: string; name: string; cellCount: number; updatedAt: number }[] = []; + const prefix = `${space}:sheet:sheets:`; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(prefix)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.sheet) continue; + sheets.push({ id: doc.sheet.id, name: doc.sheet.name, cellCount: Object.keys(doc.cells || {}).length, updatedAt: doc.sheet.updatedAt }); + } + return sheets.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit); +} + // ── Module definition ── export const sheetModule: RSpaceModule = { @@ -165,11 +271,13 @@ export const sheetModule: RSpaceModule = { icon: "\u{1F4CA}", description: "Collaborative spreadsheets", scoping: { defaultScope: "space", userConfigurable: false }, + docSchemas: [{ pattern: '{space}:sheet:sheets:{sheetId}', description: 'One doc per spreadsheet', init: sheetSchema.init }], routes, externalApp: { url: "/rsheet/app", name: "dSheet", }, + async onInit(ctx) { _syncServer = ctx.syncServer; }, outputPaths: [ { path: "", diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 14f3f35c..d62e06f4 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -2347,3 +2347,32 @@ export const socialsModule: RSpaceModule = { { label: "Create a Thread", icon: "🧵", description: "Start a discussion thread", type: 'create', href: '/{space}/rsocials' }, ], }; + +// ── MI Data Export ── + +export interface MICampaignItem { + id: string; + title: string; + description: string; + platforms: string[]; + postCount: number; + updatedAt: number; +} + +export function getRecentCampaignsForMI(space: string, limit = 5): MICampaignItem[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(socialsDocId(space)); + if (!doc?.campaigns) return []; + + return Object.values(doc.campaigns) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit) + .map(c => ({ + id: c.id, + title: c.title, + description: (c.description || "").slice(0, 200), + platforms: c.platforms, + postCount: c.posts?.length ?? 0, + updatedAt: c.updatedAt, + })); +} diff --git a/modules/rspace/mod.ts b/modules/rspace/mod.ts index e487e982..e52b2d10 100644 --- a/modules/rspace/mod.ts +++ b/modules/rspace/mod.ts @@ -146,6 +146,23 @@ routes.get("/", async (c) => { return c.html(html); }); +// ── MI export ── + +export function getCanvasSummaryForMI(space: string, _limit = 5): { totalShapes: number; typeBreakdown: { type: string; count: number }[] }[] { + const doc = getDocumentData(space); + if (!doc?.shapes) return []; + const counts: Record = {}; + for (const shape of Object.values(doc.shapes as Record)) { + const tag = shape.type || "unknown"; + counts[tag] = (counts[tag] || 0) + 1; + } + const typeBreakdown = Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) + .map(([type, count]) => ({ type, count })); + return [{ totalShapes: Object.keys(doc.shapes).length, typeBreakdown }]; +} + export const canvasModule: RSpaceModule = { id: "rspace", name: "rSpace", diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 46258cd8..47746a2a 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -857,6 +857,17 @@ routes.get("/:slug", async (c) => { return c.html(result.html, result.status); }); +export function getRecentSplatsForMI(space: string, limit = 5): { id: string; title: string; format: string; status: string; createdAt: number }[] { + if (!_syncServer) return []; + const docId = splatScenesDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.items) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((s) => ({ id: s.id, title: s.title, format: s.fileFormat, status: s.status, createdAt: s.createdAt })); +} + // ── Module export ── export const splatModule: RSpaceModule = { diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 3569926b..4d3ad798 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -19,6 +19,27 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; +import * as Automerge from "@automerge/automerge"; +import { verifyToken, extractToken } from "../../server/auth"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { swagSchema, swagDocId } from './schemas'; +import type { SwagDoc } from './schemas'; + +let _syncServer: SyncServer | null = null; + +function ensureSwagDoc(space: string): SwagDoc { + const docId = swagDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init swag', (d) => { + const init = swagSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} const routes = new Hono(); @@ -957,6 +978,33 @@ routes.get("/api/admin/analytics/summary", async (c) => { }); }); +// ── CRUD: Catalog (Automerge) ── + +routes.get("/api/catalog", (c) => { + if (!_syncServer) return c.json({ designs: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureSwagDoc(space); + return c.json({ designs: Object.values(doc.designs || {}) }); +}); + +routes.post("/api/catalog", async (c) => { + const authToken = extractToken(c.req.raw.headers); + if (!authToken) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { title, productType = "sticker", description, tags = [] } = await c.req.json(); + if (!title) return c.json({ error: "title required" }, 400); + const id = crypto.randomUUID(); + const docId = swagDocId(space); + ensureSwagDoc(space); + _syncServer.changeDoc(docId, `add design ${id}`, (d) => { + d.designs[id] = { id, title, productType, artifactId: null, source: 'upload', status: 'draft', imageUrl: null, products: [], slug: null, description: description || null, tags, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.designs[id], 201); +}); + // ── Page route: swag designer ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -974,6 +1022,16 @@ routes.get("/", (c) => { })); }); +export function getRecentDesignsForMI(space: string, limit = 5): { id: string; title: string; productType: string; status: string; createdAt: number }[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(swagDocId(space)); + if (!doc?.designs) return []; + return Object.values(doc.designs) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map(d => ({ id: d.id, title: d.title, productType: d.productType, status: d.status, createdAt: d.createdAt })); +} + export const swagModule: RSpaceModule = { id: "rswag", name: "rSwag", @@ -981,6 +1039,8 @@ export const swagModule: RSpaceModule = { description: "Design print-ready swag: stickers, posters, tees", scoping: { defaultScope: 'global', userConfigurable: true }, routes, + docSchemas: [{ pattern: '{space}:swag:designs', description: 'Design catalog per space', init: swagSchema.init }], + async onInit(ctx) { _syncServer = ctx.syncServer; }, landingPage: renderLanding, standaloneDomain: "rswag.online", feeds: [ diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 6358cd18..52e8a9fc 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -809,3 +809,33 @@ export const timeModule: RSpaceModule = { { label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' }, ], }; + +// ── MI Data Export ── + +export interface MICommitmentItem { + id: string; + memberName: string; + hours: number; + skill: string; + desc: string; + status: string; +} + +export function getRecentCommitmentsForMI(space: string, limit = 5): MICommitmentItem[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(commitmentsDocId(space)); + if (!doc?.items) return []; + + return Object.values(doc.items) + .filter(c => (c.status || "active") === "active") + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map(c => ({ + id: c.id, + memberName: c.memberName, + hours: c.hours, + skill: c.skill, + desc: (c.desc || "").slice(0, 200), + status: c.status || "active", + })); +} diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index c6113a39..41ab58c5 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -749,6 +749,19 @@ function seedTemplateTrips(space: string) { console.log(`[Trips] Template seeded for "${space}": 1 trip, 2 destinations, 3 itinerary, 2 bookings`); } +export function getRecentTripsForMI(space: string, limit = 5): { id: string; title: string; status: string; destinationCount: number; startDate: string; createdAt: number }[] { + if (!_syncServer) return []; + const items: { id: string; title: string; status: string; destinationCount: number; startDate: string; createdAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(`${space}:trips:trips:`)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.trip) continue; + const t = doc.trip; + items.push({ id: t.id, title: t.title, status: t.status || "", destinationCount: Object.keys(doc.destinations || {}).length, startDate: t.startDate || "", createdAt: t.createdAt }); + } + return items.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} + export const tripsModule: RSpaceModule = { id: "rtrips", name: "rTrips", diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts index 3859deef..22894a9b 100644 --- a/modules/rtube/mod.ts +++ b/modules/rtube/mod.ts @@ -11,8 +11,28 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; +import * as Automerge from "@automerge/automerge"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { tubeSchema, tubeDocId } from './schemas'; +import type { TubeDoc } from './schemas'; import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +let _syncServer: SyncServer | null = null; + +function ensureTubeDoc(space: string): TubeDoc { + const docId = tubeDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init tube', (d) => { + const init = tubeSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + const routes = new Hono(); // ── 360split config ── @@ -407,6 +427,33 @@ routes.get("/api/live-split/hls/:sessionId/*", async (c) => { } }); +// ── CRUD: Playlists ── + +routes.get("/api/playlists", (c) => { + if (!_syncServer) return c.json({ playlists: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureTubeDoc(space); + return c.json({ playlists: Object.values(doc.playlists || {}) }); +}); + +routes.post("/api/playlists", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + const space = c.req.param("space") || "demo"; + const { name, entries = [] } = await c.req.json(); + if (!name) return c.json({ error: "name required" }, 400); + const id = crypto.randomUUID(); + const docId = tubeDocId(space); + ensureTubeDoc(space); + _syncServer.changeDoc(docId, `create playlist ${id}`, (d) => { + d.playlists[id] = { id, name, entries, createdBy: null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.playlists[id], 201); +}); + // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -423,6 +470,16 @@ routes.get("/", (c) => { })); }); +export function getRecentVideosForMI(space: string, limit = 5): { id: string; name: string; entryCount: number; createdAt: number }[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(tubeDocId(space)); + if (!doc?.playlists) return []; + return Object.values(doc.playlists) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map(p => ({ id: p.id, name: p.name, entryCount: p.entries.length, createdAt: p.createdAt })); +} + export const tubeModule: RSpaceModule = { id: "rtube", name: "rTube", @@ -430,6 +487,8 @@ export const tubeModule: RSpaceModule = { description: "Community video hosting & live streaming", scoping: { defaultScope: 'global', userConfigurable: true }, routes, + docSchemas: [{ pattern: '{space}:tube:playlists', description: 'Playlists and watch party per space', init: tubeSchema.init }], + async onInit(ctx) { _syncServer = ctx.syncServer; }, landingPage: renderLanding, standaloneDomain: "rtube.online", feeds: [ diff --git a/modules/rvnb/mod.ts b/modules/rvnb/mod.ts index 99a98bb4..f43cf0c7 100644 --- a/modules/rvnb/mod.ts +++ b/modules/rvnb/mod.ts @@ -1288,6 +1288,20 @@ routes.get("/", (c) => { })); }); +// ── MI export ── + +export function getActiveVehiclesForMI(space: string, limit = 5): { id: string; title: string; type: string; locationName: string; economy: string; createdAt: number }[] { + if (!_syncServer) return []; + const docId = vnbDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) return []; + return Object.values(doc.vehicles) + .filter((v) => v.isActive) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map((v) => ({ id: v.id, title: v.title, type: v.type, locationName: v.pickupLocationName || "", economy: v.economy, createdAt: v.createdAt })); +} + // ── Module export ── export const vnbModule: RSpaceModule = { diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index a0b6c2ed..271a14c0 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -773,6 +773,19 @@ routes.get("/", (c) => { })); }); +export function getActiveProposalsForMI(space: string, limit = 5): { id: string; title: string; status: string; score: number; voteCount: number; createdAt: number }[] { + if (!_syncServer) return []; + const items: { id: string; title: string; status: string; score: number; voteCount: number; createdAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(`${space}:vote:proposals:`)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.proposal) continue; + const p = doc.proposal; + items.push({ id: p.id, title: p.title, status: p.status, score: p.score || 0, voteCount: Object.keys(doc.votes || {}).length, createdAt: p.createdAt }); + } + return items.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} + export const voteModule: RSpaceModule = { id: "rvote", name: "rVote", diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index ffde6859..8570a4bd 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -1289,6 +1289,23 @@ routes.get("/transactions", (c) => c.html(renderWallet(c.req.param("space") || " routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo", "budget"))); +// ── MI export ── + +export function getCrdtTokensForMI(_space: string, limit = 5): { tokenId: string; name: string; symbol: string; totalSupply: number }[] { + const docIds = listTokenDocs(); + return docIds.slice(0, limit).flatMap((docId) => { + const tokenId = docId.replace('global:tokens:ledgers:', ''); + const doc = getTokenDoc(tokenId); + if (!doc || !doc.token.name) return []; + return [{ + tokenId: doc.token.id, + name: doc.token.name, + symbol: doc.token.symbol, + totalSupply: doc.token.totalSupply, + }]; + }); +} + export const walletModule: RSpaceModule = { id: "rwallet", name: "rWallet", diff --git a/server/index.ts b/server/index.ts index 135290c9..a5bd42ab 100644 --- a/server/index.ts +++ b/server/index.ts @@ -43,6 +43,7 @@ import { import type { SpaceAuthConfig } from "@encryptid/sdk/server"; import { verifyToken, extractToken } from "./auth"; import type { EncryptIDClaims } from "./auth"; +import { createMcpRouter } from "./mcp-server"; const spaceAuthOpts = () => ({ getSpaceConfig, @@ -528,6 +529,9 @@ app.route("/api/rtasks", checklistApiRoutes); // ── Bug Report API ── app.route("/api/bug-report", bugReportRouter); +// ── MCP Server (Model Context Protocol) ── +app.route("/api/mcp", createMcpRouter(syncServer)); + // ── Magic Link Responses (top-level, bypasses space auth) ── app.route("/respond", magicLinkRoutes); diff --git a/server/mcp-server.ts b/server/mcp-server.ts new file mode 100644 index 00000000..58f0712d --- /dev/null +++ b/server/mcp-server.ts @@ -0,0 +1,118 @@ +/** + * In-process MCP server for rSpace. + * + * Exposes rSpace module data as MCP tools via Streamable HTTP at /api/mcp. + * Stateless mode: fresh McpServer + transport per request. + * Direct Automerge syncServer access for reads (no HTTP round-trip). + * + * 101 tools across 35 groups: + * spaces (2), rcal (4), rnotes (5), rtasks (5), rwallet (4), + * rsocials (4), rnetwork (3), rinbox (4), rtime (4), rfiles (3), rschedule (4), + * rvote (3), rchoices (3), rtrips (4), rcart (4), rexchange (4), rbnb (4), + * rvnb (3), crowdsurf (2), rbooks (2), rpubs (2), rmeets (2), rtube (2), + * rswag (2), rdesign (2), rsplat (2), rphotos (2), rflows (2), rdocs (1), + * rdata (1), rforum (2), rchats (3), rmaps (3), rsheet (2), rgov (2) + * 1 resource: rspace://spaces/{slug} + */ + +import { Hono } from "hono"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import type { SyncServer } from "./local-first/sync-server"; + +import { registerSpacesTools } from "./mcp-tools/spaces"; +import { registerCalTools } from "./mcp-tools/rcal"; +import { registerNotesTools } from "./mcp-tools/rnotes"; +import { registerTasksTools } from "./mcp-tools/rtasks"; +import { registerWalletTools } from "./mcp-tools/rwallet"; +import { registerSocialsTools } from "./mcp-tools/rsocials"; +import { registerNetworkTools } from "./mcp-tools/rnetwork"; +import { registerInboxTools } from "./mcp-tools/rinbox"; +import { registerTimeTools } from "./mcp-tools/rtime"; +import { registerFilesTools } from "./mcp-tools/rfiles"; +import { registerScheduleTools } from "./mcp-tools/rschedule"; +import { registerVoteTools } from "./mcp-tools/rvote"; +import { registerChoicesTools } from "./mcp-tools/rchoices"; +import { registerTripsTools } from "./mcp-tools/rtrips"; +import { registerCartTools } from "./mcp-tools/rcart"; +import { registerExchangeTools } from "./mcp-tools/rexchange"; +import { registerBnbTools } from "./mcp-tools/rbnb"; +import { registerVnbTools } from "./mcp-tools/rvnb"; +import { registerCrowdSurfTools } from "./mcp-tools/crowdsurf"; +import { registerBooksTools } from "./mcp-tools/rbooks"; +import { registerPubsTools } from "./mcp-tools/rpubs"; +import { registerMeetsTools } from "./mcp-tools/rmeets"; +import { registerTubeTools } from "./mcp-tools/rtube"; +import { registerSwagTools } from "./mcp-tools/rswag"; +import { registerDesignTools } from "./mcp-tools/rdesign"; +import { registerSplatTools } from "./mcp-tools/rsplat"; +import { registerPhotosTools } from "./mcp-tools/rphotos"; +import { registerFlowsTools } from "./mcp-tools/rflows"; +import { registerDocsTools } from "./mcp-tools/rdocs"; +import { registerDataTools } from "./mcp-tools/rdata"; +import { registerForumTools } from "./mcp-tools/rforum"; +import { registerChatsTools } from "./mcp-tools/rchats"; +import { registerMapsTools } from "./mcp-tools/rmaps"; +import { registerSheetTools } from "./mcp-tools/rsheet"; +import { registerGovTools } from "./mcp-tools/rgov"; + +function createMcpServerInstance(syncServer: SyncServer): McpServer { + const server = new McpServer({ + name: "rspace", + version: "1.0.0", + }); + + registerSpacesTools(server); + registerCalTools(server, syncServer); + registerNotesTools(server, syncServer); + registerTasksTools(server, syncServer); + registerWalletTools(server); + registerSocialsTools(server, syncServer); + registerNetworkTools(server, syncServer); + registerInboxTools(server, syncServer); + registerTimeTools(server, syncServer); + registerFilesTools(server, syncServer); + registerScheduleTools(server, syncServer); + registerVoteTools(server, syncServer); + registerChoicesTools(server, syncServer); + registerTripsTools(server, syncServer); + registerCartTools(server, syncServer); + registerExchangeTools(server, syncServer); + registerBnbTools(server, syncServer); + registerVnbTools(server, syncServer); + registerCrowdSurfTools(server, syncServer); + registerBooksTools(server, syncServer); + registerPubsTools(server, syncServer); + registerMeetsTools(server, syncServer); + registerTubeTools(server, syncServer); + registerSwagTools(server, syncServer); + registerDesignTools(server, syncServer); + registerSplatTools(server, syncServer); + registerPhotosTools(server, syncServer); + registerFlowsTools(server, syncServer); + registerDocsTools(server, syncServer); + registerDataTools(server, syncServer); + registerForumTools(server, syncServer); + registerChatsTools(server, syncServer); + registerMapsTools(server, syncServer); + registerSheetTools(server, syncServer); + registerGovTools(server); + + return server; +} + +export function createMcpRouter(syncServer: SyncServer): Hono { + const router = new Hono(); + + router.all("/*", async (c) => { + const server = createMcpServerInstance(syncServer); + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + await server.connect(transport); + const response = await transport.handleRequest(c.req.raw); + return response; + }); + + return router; +} diff --git a/server/mcp-tools/_auth.ts b/server/mcp-tools/_auth.ts new file mode 100644 index 00000000..7d8ce014 --- /dev/null +++ b/server/mcp-tools/_auth.ts @@ -0,0 +1,102 @@ +/** + * MCP auth helper — centralised access control for all MCP tools. + * + * Every tool calls resolveAccess() before touching data. + * + * Access matrix: + * public → read open (unless forceAuth), write requires token+member + * permissioned → read requires any token, write requires token+member + * private → read+write require token+member + */ + +import { verifyToken } from "../auth"; +import type { EncryptIDClaims } from "../auth"; +import { resolveCallerRole, roleAtLeast } from "../spaces"; +import type { SpaceRoleString } from "../spaces"; +import { loadCommunity, getDocumentData, normalizeVisibility } from "../community-store"; + +export interface AccessResult { + allowed: boolean; + claims: EncryptIDClaims | null; + role: SpaceRoleString; + reason?: string; +} + +/** + * Resolve access for an MCP tool call. + * + * @param token JWT string (may be undefined for unauthenticated callers) + * @param space Space slug + * @param forWrite true for mutating operations + * @param forceAuth true to always require token+member (e.g. rinbox) + */ +export async function resolveAccess( + token: string | undefined, + space: string, + forWrite = false, + forceAuth = false, +): Promise { + // Load space doc + await loadCommunity(space); + const data = getDocumentData(space); + if (!data) { + return { allowed: false, claims: null, role: "viewer", reason: "Space not found or access denied" }; + } + + const visibility = normalizeVisibility(data.meta.visibility || "private"); + + // Verify token if provided + let claims: EncryptIDClaims | null = null; + if (token) { + try { + claims = await verifyToken(token); + } catch { + return { allowed: false, claims: null, role: "viewer", reason: "Invalid or expired token" }; + } + } + + // Resolve caller's role in this space + const resolved = claims ? await resolveCallerRole(space, claims) : null; + const role: SpaceRoleString = resolved?.role ?? "viewer"; + const isMember = roleAtLeast(role, "member"); + + // Write always requires token + member + if (forWrite) { + if (!claims) return { allowed: false, claims, role, reason: "Authentication required" }; + if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" }; + return { allowed: true, claims, role }; + } + + // forceAuth → always requires token + member (e.g. email/inbox) + if (forceAuth) { + if (!claims) { + // Don't reveal that the space exists for private spaces + return { allowed: false, claims, role, reason: "Space not found or access denied" }; + } + if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" }; + return { allowed: true, claims, role }; + } + + // Read access by visibility + switch (visibility) { + case "public": + return { allowed: true, claims, role }; + + case "permissioned": + if (!claims) return { allowed: false, claims, role, reason: "Authentication required" }; + return { allowed: true, claims, role }; + + case "private": + if (!claims) return { allowed: false, claims, role, reason: "Space not found or access denied" }; + if (!isMember) return { allowed: false, claims, role, reason: "Space not found or access denied" }; + return { allowed: true, claims, role }; + + default: + return { allowed: false, claims, role, reason: "Space not found or access denied" }; + } +} + +/** Standard MCP error response for denied access. */ +export function accessDeniedResponse(reason: string) { + return { content: [{ type: "text" as const, text: JSON.stringify({ error: reason }) }], isError: true }; +} diff --git a/server/mcp-tools/crowdsurf.ts b/server/mcp-tools/crowdsurf.ts new file mode 100644 index 00000000..468626ae --- /dev/null +++ b/server/mcp-tools/crowdsurf.ts @@ -0,0 +1,87 @@ +/** + * MCP tools for CrowdSurf (community activity prompts with swipe commitment). + * + * Tools: crowdsurf_list_prompts, crowdsurf_get_prompt + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { crowdsurfDocId, getRightSwipeCount, getUrgency } from "../../modules/crowdsurf/schemas"; +import type { CrowdSurfDoc } from "../../modules/crowdsurf/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerCrowdSurfTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "crowdsurf_list_prompts", + "List activity prompts with swipe counts and urgency", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + active_only: z.boolean().optional().describe("Exclude triggered/expired prompts (default true)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, active_only, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(crowdsurfDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No crowdsurf data found" }) }] }; + + let prompts = Object.values(doc.prompts || {}); + if (active_only !== false) { + prompts = prompts.filter(p => !p.triggered && !p.expired); + } + + prompts.sort((a, b) => b.elo - a.elo); + prompts = prompts.slice(0, limit || 50); + + const summary = prompts.map(p => ({ + id: p.id, text: p.text, location: p.location, + threshold: p.threshold, duration: p.duration, + rightSwipes: getRightSwipeCount(p), + urgency: getUrgency(p), + elo: p.elo, comparisons: p.comparisons, + triggered: p.triggered, expired: p.expired, + createdAt: p.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "crowdsurf_get_prompt", + "Get full prompt details with swipe breakdown and contributions", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + prompt_id: z.string().describe("Prompt ID"), + }, + async ({ space, token, prompt_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(crowdsurfDocId(space)); + const prompt = doc?.prompts?.[prompt_id]; + if (!prompt) return { content: [{ type: "text", text: JSON.stringify({ error: "Prompt not found" }) }] }; + + const swipes = Object.entries(prompt.swipes || {}).map(([did, s]) => ({ + did, direction: s.direction, timestamp: s.timestamp, + contribution: s.contribution || null, + })); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + ...prompt, + swipes, + rightSwipeCount: getRightSwipeCount(prompt), + urgency: getUrgency(prompt), + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rbnb.ts b/server/mcp-tools/rbnb.ts new file mode 100644 index 00000000..916ce9b4 --- /dev/null +++ b/server/mcp-tools/rbnb.ts @@ -0,0 +1,125 @@ +/** + * MCP tools for rBnb (community hospitality sharing). + * + * Tools: rbnb_list_listings, rbnb_get_listing, rbnb_list_stays, rbnb_list_endorsements + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { bnbDocId } from "../../modules/rbnb/schemas"; +import type { BnbDoc } from "../../modules/rbnb/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerBnbTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rbnb_list_listings", + "List hospitality listings (rooms, couches, cabins, etc.)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by type (couch, room, apartment, cabin, etc.)"), + active_only: z.boolean().optional().describe("Only active listings (default true)"), + }, + async ({ space, token, type, active_only }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(bnbDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No bnb data found" }) }] }; + + let listings = Object.values(doc.listings || {}); + if (active_only !== false) listings = listings.filter(l => l.isActive); + if (type) listings = listings.filter(l => l.type === type); + + const summary = listings.map(l => ({ + id: l.id, hostName: l.hostName, title: l.title, + type: l.type, economy: l.economy, + locationName: l.locationName, guestCapacity: l.guestCapacity, + suggestedAmount: l.suggestedAmount, currency: l.currency, + amenities: l.amenities, isActive: l.isActive, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rbnb_get_listing", + "Get full listing details with availability windows", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + listing_id: z.string().describe("Listing ID"), + }, + async ({ space, token, listing_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(bnbDocId(space)); + const listing = doc?.listings?.[listing_id]; + if (!listing) return { content: [{ type: "text", text: JSON.stringify({ error: "Listing not found" }) }] }; + + const availability = Object.values(doc!.availability || {}) + .filter(a => a.listingId === listing_id); + + return { content: [{ type: "text", text: JSON.stringify({ listing, availability }, null, 2) }] }; + }, + ); + + server.tool( + "rbnb_list_stays", + "List stay requests with status", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (pending, accepted, declined, completed, etc.)"), + }, + async ({ space, token, status }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(bnbDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No bnb data found" }) }] }; + + let stays = Object.values(doc.stays || {}); + if (status) stays = stays.filter(s => s.status === status); + stays.sort((a, b) => b.requestedAt - a.requestedAt); + + const summary = stays.map(s => ({ + id: s.id, listingId: s.listingId, + guestName: s.guestName, status: s.status, + checkIn: s.checkIn, checkOut: s.checkOut, + guestCount: s.guestCount, messageCount: s.messages?.length ?? 0, + requestedAt: s.requestedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rbnb_list_endorsements", + "List endorsements (reviews) between hosts and guests", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + listing_id: z.string().optional().describe("Filter by listing ID"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, listing_id, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(bnbDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No bnb data found" }) }] }; + + let endorsements = Object.values(doc.endorsements || {}); + if (listing_id) endorsements = endorsements.filter(e => e.listingId === listing_id); + endorsements.sort((a, b) => b.createdAt - a.createdAt); + endorsements = endorsements.slice(0, limit || 50); + + return { content: [{ type: "text", text: JSON.stringify(endorsements, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rbooks.ts b/server/mcp-tools/rbooks.ts new file mode 100644 index 00000000..b4a43ef5 --- /dev/null +++ b/server/mcp-tools/rbooks.ts @@ -0,0 +1,78 @@ +/** + * 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) }] }; + }, + ); +} diff --git a/server/mcp-tools/rcal.ts b/server/mcp-tools/rcal.ts new file mode 100644 index 00000000..5f5d7b2b --- /dev/null +++ b/server/mcp-tools/rcal.ts @@ -0,0 +1,219 @@ +/** + * MCP tools for rCal (calendar). + * + * Tools: rcal_list_events, rcal_get_event, rcal_create_event, rcal_update_event + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { calendarDocId } from "../../modules/rcal/schemas"; +import type { CalendarDoc, CalendarEvent } from "../../modules/rcal/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerCalTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rcal_list_events", + "List calendar events in a space. Supports filtering by date range, search text, and tags.", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + start: z.number().optional().describe("Start time filter (epoch ms)"), + end: z.number().optional().describe("End time filter (epoch ms)"), + search: z.string().optional().describe("Search in title/description"), + limit: z.number().optional().describe("Max results (default 50)"), + upcoming_days: z.number().optional().describe("Show events in next N days"), + tags: z.array(z.string()).optional().describe("Filter by tags"), + }, + async ({ space, token, start, end, search, limit, upcoming_days, tags }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(calendarDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }] }; + } + + let events = Object.values(doc.events || {}); + + if (upcoming_days) { + const now = Date.now(); + const cutoff = now + upcoming_days * 86400000; + events = events.filter(e => e.endTime >= now && e.startTime <= cutoff); + } else { + if (start) events = events.filter(e => e.endTime >= start); + if (end) events = events.filter(e => e.startTime <= end); + } + + if (search) { + const q = search.toLowerCase(); + events = events.filter(e => + e.title.toLowerCase().includes(q) || + (e.description && e.description.toLowerCase().includes(q)), + ); + } + + if (tags && tags.length > 0) { + events = events.filter(e => + e.tags && tags.some(t => e.tags!.includes(t)), + ); + } + + events.sort((a, b) => a.startTime - b.startTime); + const maxResults = limit || 50; + events = events.slice(0, maxResults); + + const summary = events.map(e => ({ + id: e.id, + title: e.title, + startTime: e.startTime, + endTime: e.endTime, + allDay: e.allDay, + status: e.status, + tags: e.tags, + locationName: e.locationName, + attendeeCount: e.attendeeCount, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rcal_get_event", + "Get full details of a specific calendar event", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + event_id: z.string().describe("Event ID"), + }, + async ({ space, token, event_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(calendarDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }] }; + } + const event = doc.events?.[event_id]; + if (!event) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }] }; + } + return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] }; + }, + ); + + server.tool( + "rcal_create_event", + "Create a new calendar event (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + title: z.string().describe("Event title"), + start_time: z.number().describe("Start time (epoch ms)"), + end_time: z.number().describe("End time (epoch ms)"), + description: z.string().optional().describe("Event description"), + all_day: z.boolean().optional().describe("All-day event"), + location_name: z.string().optional().describe("Location name"), + tags: z.array(z.string()).optional().describe("Event tags"), + }, + async ({ space, token, title, start_time, end_time, description, all_day, location_name, tags }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = calendarDocId(space); + let doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }], isError: true }; + } + + const eventId = `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + + syncServer.changeDoc(docId, `Create event ${title}`, (d) => { + if (!d.events) (d as any).events = {}; + d.events[eventId] = { + id: eventId, + title, + description: description || "", + startTime: start_time, + endTime: end_time, + allDay: all_day || false, + timezone: null, + rrule: null, + status: null, + likelihood: null, + visibility: null, + sourceId: null, + sourceName: null, + sourceType: "mcp", + sourceColor: null, + locationId: null, + locationName: location_name || null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: null, + rToolEntityId: null, + locationBreadcrumb: null, + bookingStatus: null, + attendees: [], + attendeeCount: 0, + tags: tags || null, + metadata: null, + createdAt: now, + updatedAt: now, + } as CalendarEvent; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: eventId, created: true }) }] }; + }, + ); + + server.tool( + "rcal_update_event", + "Update an existing calendar event (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + event_id: z.string().describe("Event ID to update"), + title: z.string().optional().describe("New title"), + start_time: z.number().optional().describe("New start time (epoch ms)"), + end_time: z.number().optional().describe("New end time (epoch ms)"), + description: z.string().optional().describe("New description"), + all_day: z.boolean().optional().describe("All-day event"), + location_name: z.string().optional().describe("New location"), + tags: z.array(z.string()).optional().describe("New tags"), + status: z.string().optional().describe("New status"), + }, + async ({ space, token, event_id, ...updates }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = calendarDocId(space); + const doc = syncServer.getDoc(docId); + if (!doc?.events?.[event_id]) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }], isError: true }; + } + + syncServer.changeDoc(docId, `Update event ${event_id}`, (d) => { + const e = d.events[event_id]; + if (updates.title !== undefined) e.title = updates.title; + if (updates.start_time !== undefined) e.startTime = updates.start_time; + if (updates.end_time !== undefined) e.endTime = updates.end_time; + if (updates.description !== undefined) e.description = updates.description; + if (updates.all_day !== undefined) e.allDay = updates.all_day; + if (updates.location_name !== undefined) e.locationName = updates.location_name; + if (updates.tags !== undefined) e.tags = updates.tags; + if (updates.status !== undefined) e.status = updates.status; + e.updatedAt = Date.now(); + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: event_id, updated: true }) }] }; + }, + ); +} diff --git a/server/mcp-tools/rcart.ts b/server/mcp-tools/rcart.ts new file mode 100644 index 00000000..e67db0b7 --- /dev/null +++ b/server/mcp-tools/rcart.ts @@ -0,0 +1,151 @@ +/** + * MCP tools for rCart (catalog, shopping carts, group buys, payments). + * + * Tools: rcart_list_catalog, rcart_list_carts, rcart_get_cart, rcart_list_group_buys + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { catalogDocId, shoppingCartIndexDocId } from "../../modules/rcart/schemas"; +import type { CatalogDoc, ShoppingCartIndexDoc, ShoppingCartDoc, GroupBuyDoc } from "../../modules/rcart/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const CART_PREFIX = ":cart:shopping:"; +const GROUP_BUY_PREFIX = ":cart:group-buys:"; + +export function registerCartTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rcart_list_catalog", + "List product catalog entries in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + search: z.string().optional().describe("Search in title/tags"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, search, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(catalogDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No catalog found" }) }] }; + } + + let items = Object.values(doc.items || {}); + if (search) { + const q = search.toLowerCase(); + items = items.filter(i => + i.title.toLowerCase().includes(q) || + i.tags.some(t => t.toLowerCase().includes(q)), + ); + } + items = items.slice(0, limit || 50); + + const summary = items.map(i => ({ + id: i.id, + title: i.title, + productType: i.productType, + substrates: i.substrates, + tags: i.tags, + status: i.status, + createdAt: i.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rcart_list_carts", + "List shopping carts in a space with status and funding", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (OPEN, FUNDING, FUNDED, ORDERED, CLOSED)"), + }, + async ({ space, token, status }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const indexDoc = syncServer.getDoc(shoppingCartIndexDocId(space)); + if (!indexDoc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No carts found" }) }] }; + } + + let carts = Object.entries(indexDoc.carts || {}).map(([id, c]) => ({ id, ...c })); + if (status) carts = carts.filter(c => c.status === status); + + return { content: [{ type: "text", text: JSON.stringify(carts, null, 2) }] }; + }, + ); + + server.tool( + "rcart_get_cart", + "Get full shopping cart with items and contributions", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + cart_id: z.string().describe("Cart ID"), + }, + async ({ space, token, cart_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${CART_PREFIX}${cart_id}`; + const doc = syncServer.getDoc(docId); + if (!doc?.cart) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Cart not found" }) }] }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + cart: doc.cart, + items: Object.values(doc.items || {}), + contributions: Object.values(doc.contributions || {}), + eventCount: doc.events?.length ?? 0, + }, null, 2), + }], + }; + }, + ); + + server.tool( + "rcart_list_group_buys", + "List group buys with pledge tallies", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (OPEN, LOCKED, ORDERED, CANCELLED)"), + }, + async ({ space, token, status }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const prefix = `${space}${GROUP_BUY_PREFIX}`; + const docIds = syncServer.getDocIds().filter(id => id.startsWith(prefix)); + let buys: any[] = []; + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.buy) continue; + buys.push({ + id: doc.buy.id, + title: doc.buy.title, + status: doc.buy.status, + totalPledged: doc.buy.totalPledged, + pledgeCount: Object.keys(doc.pledges || {}).length, + tiers: doc.buy.tiers, + closesAt: doc.buy.closesAt, + createdAt: doc.buy.createdAt, + }); + } + + if (status) buys = buys.filter(b => b.status === status); + return { content: [{ type: "text", text: JSON.stringify(buys, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rchats.ts b/server/mcp-tools/rchats.ts new file mode 100644 index 00000000..4eee20b9 --- /dev/null +++ b/server/mcp-tools/rchats.ts @@ -0,0 +1,96 @@ +/** + * MCP tools for rChats (multiplayer chat channels). + * forceAuth=true — chat messages are always sensitive. + * + * Tools: rchats_list_channels, rchats_get_channel, rchats_list_messages + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { chatsDirectoryDocId, chatChannelDocId } from "../../modules/rchats/schemas"; +import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +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 = Object.values(doc.channels || {}).map(ch => ({ + id: ch.id, name: ch.name, description: ch.description, + isPrivate: ch.isPrivate, 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 }, 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 = Object.values(doc.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, + editedAt: m.editedAt, createdAt: m.createdAt, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rchoices.ts b/server/mcp-tools/rchoices.ts new file mode 100644 index 00000000..1c105d3d --- /dev/null +++ b/server/mcp-tools/rchoices.ts @@ -0,0 +1,129 @@ +/** + * MCP tools for rChoices (voting sessions — vote/rank/score). + * + * Tools: rchoices_list_sessions, rchoices_get_session, rchoices_get_results + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { choicesDocId } from "../../modules/rchoices/schemas"; +import type { ChoicesDoc } from "../../modules/rchoices/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerChoicesTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rchoices_list_sessions", + "List voting/ranking sessions in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by type (vote, rank, score)"), + include_closed: z.boolean().optional().describe("Include closed sessions (default false)"), + }, + async ({ space, token, type, include_closed }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(choicesDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No choices data found" }) }] }; + } + + let sessions = Object.values(doc.sessions || {}); + if (!include_closed) sessions = sessions.filter(s => !s.closed); + if (type) sessions = sessions.filter(s => s.type === type); + + const summary = sessions.map(s => ({ + id: s.id, + title: s.title, + type: s.type, + mode: s.mode, + optionCount: s.options?.length ?? 0, + closed: s.closed, + createdAt: s.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rchoices_get_session", + "Get full details of a voting session including options", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + session_id: z.string().describe("Session ID"), + }, + async ({ space, token, session_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(choicesDocId(space)); + const session = doc?.sessions?.[session_id]; + if (!session) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Session not found" }) }] }; + } + + const voteCount = Object.values(doc!.votes || {}) + .filter(v => v.choices && Object.keys(v.choices).some(k => session.options?.some(o => o.id === k))) + .length; + + return { content: [{ type: "text", text: JSON.stringify({ ...session, voteCount }, null, 2) }] }; + }, + ); + + server.tool( + "rchoices_get_results", + "Get tallied results for a voting session", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + session_id: z.string().describe("Session ID"), + }, + async ({ space, token, session_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(choicesDocId(space)); + const session = doc?.sessions?.[session_id]; + if (!session) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Session not found" }) }] }; + } + + const optionIds = new Set(session.options?.map(o => o.id) || []); + const relevantVotes = Object.values(doc!.votes || {}) + .filter(v => v.choices && Object.keys(v.choices).some(k => optionIds.has(k))); + + // Tally + const tallies: Record = {}; + for (const opt of session.options || []) { + tallies[opt.id] = { label: opt.label, totalScore: 0, voteCount: 0 }; + } + for (const vote of relevantVotes) { + for (const [optId, score] of Object.entries(vote.choices || {})) { + if (tallies[optId]) { + tallies[optId].totalScore += score; + tallies[optId].voteCount++; + } + } + } + + const results = Object.entries(tallies) + .map(([id, t]) => ({ id, ...t, avgScore: t.voteCount > 0 ? t.totalScore / t.voteCount : 0 })) + .sort((a, b) => b.totalScore - a.totalScore); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + session: { id: session.id, title: session.title, type: session.type, closed: session.closed }, + totalVoters: relevantVotes.length, + results, + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rdata.ts b/server/mcp-tools/rdata.ts new file mode 100644 index 00000000..6e5a91c2 --- /dev/null +++ b/server/mcp-tools/rdata.ts @@ -0,0 +1,40 @@ +/** + * MCP tools for rData (analytics dashboard config). + * + * Tools: rdata_list_tracked_apps + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { dataDocId } from "../../modules/rdata/schemas"; +import type { DataDoc } from "../../modules/rdata/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerDataTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rdata_list_tracked_apps", + "List tracked analytics apps and dashboard config", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(dataDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No data config found" }) }] }; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + trackedApps: Object.values(doc.trackedApps || {}), + dashboardConfig: doc.dashboardConfig, + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rdesign.ts b/server/mcp-tools/rdesign.ts new file mode 100644 index 00000000..75326054 --- /dev/null +++ b/server/mcp-tools/rdesign.ts @@ -0,0 +1,70 @@ +/** + * MCP tools for rDesign (document design / page layout). + * + * Tools: rdesign_get_document, rdesign_list_frames + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { designDocId } from "../../modules/rdesign/schemas"; +import type { DesignDoc } from "../../modules/rdesign/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerDesignTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rdesign_get_document", + "Get design document overview with pages", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(designDocId(space)); + if (!doc?.document) return { content: [{ type: "text", text: JSON.stringify({ error: "No design document found" }) }] }; + + const pages = Object.values(doc.document.pages || {}); + const frameCount = Object.keys(doc.document.frames || {}).length; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + title: doc.document.title, + unit: doc.document.unit, + pageCount: pages.length, + pages, + frameCount, + }, null, 2), + }], + }; + }, + ); + + server.tool( + "rdesign_list_frames", + "List design frames on a specific page", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + page_number: z.number().optional().describe("Filter by page number"), + type: z.string().optional().describe("Filter by frame type (text, image, rect, ellipse)"), + }, + async ({ space, token, page_number, type }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(designDocId(space)); + if (!doc?.document) return { content: [{ type: "text", text: JSON.stringify({ error: "No design document found" }) }] }; + + let frames = Object.values(doc.document.frames || {}); + if (page_number !== undefined) frames = frames.filter(f => f.page === page_number); + if (type) frames = frames.filter(f => f.type === type); + + return { content: [{ type: "text", text: JSON.stringify(frames, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rdocs.ts b/server/mcp-tools/rdocs.ts new file mode 100644 index 00000000..f9e38847 --- /dev/null +++ b/server/mcp-tools/rdocs.ts @@ -0,0 +1,39 @@ +/** + * MCP tools for rDocs (linked documents). + * + * Tools: rdocs_list_documents + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { docsDocId } from "../../modules/rdocs/schemas"; +import type { DocsDoc } from "../../modules/rdocs/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerDocsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rdocs_list_documents", + "List linked documents in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + search: z.string().optional().describe("Search in title"), + }, + async ({ space, token, search }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(docsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No docs data found" }) }] }; + + let documents = Object.values(doc.linkedDocuments || {}); + if (search) { + const q = search.toLowerCase(); + documents = documents.filter(d => d.title.toLowerCase().includes(q)); + } + + return { content: [{ type: "text", text: JSON.stringify(documents, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rexchange.ts b/server/mcp-tools/rexchange.ts new file mode 100644 index 00000000..2d640511 --- /dev/null +++ b/server/mcp-tools/rexchange.ts @@ -0,0 +1,141 @@ +/** + * MCP tools for rExchange (P2P trading). + * + * Tools: rexchange_list_intents, rexchange_list_trades, + * rexchange_list_pools, rexchange_get_reputation + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { + exchangeIntentsDocId, exchangeTradesDocId, + exchangePoolsDocId, exchangeReputationDocId, +} from "../../modules/rexchange/schemas"; +import type { + ExchangeIntentsDoc, ExchangeTradesDoc, + ExchangePoolsDoc, ExchangeReputationDoc, +} from "../../modules/rexchange/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerExchangeTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rexchange_list_intents", + "List P2P trading intents (buy/sell offers)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + side: z.string().optional().describe("Filter by side (buy or sell)"), + status: z.string().optional().describe("Filter by status (active, matched, completed, cancelled, expired)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, side, status, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(exchangeIntentsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No exchange data found" }) }] }; + + let intents = Object.values(doc.intents || {}); + if (side) intents = intents.filter(i => i.side === side); + if (status) intents = intents.filter(i => i.status === status); + intents.sort((a, b) => b.createdAt - a.createdAt); + intents = intents.slice(0, limit || 50); + + const summary = intents.map(i => ({ + id: i.id, creatorName: i.creatorName, side: i.side, + tokenId: i.tokenId, fiatCurrency: i.fiatCurrency, + tokenAmountMin: i.tokenAmountMin, tokenAmountMax: i.tokenAmountMax, + rateType: i.rateType, rateFixed: i.rateFixed, + paymentMethods: i.paymentMethods, status: i.status, + isStandingOrder: i.isStandingOrder, createdAt: i.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rexchange_list_trades", + "List P2P trades with status and amounts", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by trade status"), + limit: z.number().optional().describe("Max results (default 20)"), + }, + async ({ space, token, status, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(exchangeTradesDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No trades found" }) }] }; + + let trades = Object.values(doc.trades || {}); + if (status) trades = trades.filter(t => t.status === status); + trades.sort((a, b) => b.createdAt - a.createdAt); + trades = trades.slice(0, limit || 20); + + const summary = trades.map(t => ({ + id: t.id, buyerName: t.buyerName, sellerName: t.sellerName, + tokenId: t.tokenId, tokenAmount: t.tokenAmount, + fiatCurrency: t.fiatCurrency, fiatAmount: t.fiatAmount, + agreedRate: t.agreedRate, paymentMethod: t.paymentMethod, + status: t.status, chatMessageCount: t.chatMessages?.length ?? 0, + createdAt: t.createdAt, completedAt: t.completedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rexchange_list_pools", + "List liquidity pool positions", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(exchangePoolsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No pools found" }) }] }; + + const positions = Object.values(doc.positions || {}).map(p => ({ + id: p.id, creatorName: p.creatorName, + tokenId: p.tokenId, fiatCurrency: p.fiatCurrency, + tokenRemaining: p.tokenRemaining, fiatRemaining: p.fiatRemaining, + spreadBps: p.spreadBps, feesEarnedToken: p.feesEarnedToken, + feesEarnedFiat: p.feesEarnedFiat, tradesMatched: p.tradesMatched, + status: p.status, + })); + + return { content: [{ type: "text", text: JSON.stringify(positions, null, 2) }] }; + }, + ); + + server.tool( + "rexchange_get_reputation", + "Get trader reputation scores", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + did: z.string().optional().describe("Filter by specific DID"), + }, + async ({ space, token, did }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(exchangeReputationDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No reputation data found" }) }] }; + + let records = Object.values(doc.records || {}); + if (did) records = records.filter(r => r.did === did); + records.sort((a, b) => b.score - a.score); + + return { content: [{ type: "text", text: JSON.stringify(records, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rfiles.ts b/server/mcp-tools/rfiles.ts new file mode 100644 index 00000000..c2cc96e6 --- /dev/null +++ b/server/mcp-tools/rfiles.ts @@ -0,0 +1,161 @@ +/** + * MCP tools for rFiles (file metadata & memory cards). + * + * Tools: rfiles_list_files, rfiles_get_file, rfiles_list_cards + * Read-only. Omits storagePath and fileHash from all responses. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import type { FilesDoc } from "../../modules/rfiles/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const FILES_PREFIX = ":files:cards:"; + +/** Find all files docIds for a space. */ +function findFilesDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${FILES_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerFilesTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rfiles_list_files", + "List file metadata in a space (omits storagePath for security)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + mime_type: z.string().optional().describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"), + search: z.string().optional().describe("Search in filename/title/tags"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, mime_type, search, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findFilesDocIds(syncServer, space); + let files: Array<{ + id: string; originalFilename: string; title: string | null; + mimeType: string | null; fileSize: number; tags: string[]; + uploadedBy: string | null; createdAt: number; updatedAt: number; + }> = []; + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.files) continue; + for (const f of Object.values(doc.files)) { + files.push({ + id: f.id, + originalFilename: f.originalFilename, + title: f.title, + mimeType: f.mimeType, + fileSize: f.fileSize, + tags: f.tags, + uploadedBy: f.uploadedBy, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + }); + } + } + + if (mime_type) { + files = files.filter(f => f.mimeType && f.mimeType.startsWith(mime_type)); + } + + if (search) { + const q = search.toLowerCase(); + files = files.filter(f => + f.originalFilename.toLowerCase().includes(q) || + (f.title && f.title.toLowerCase().includes(q)) || + f.tags.some(t => t.toLowerCase().includes(q)), + ); + } + + files.sort((a, b) => b.updatedAt - a.updatedAt); + files = files.slice(0, limit || 50); + + return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] }; + }, + ); + + server.tool( + "rfiles_get_file", + "Get detailed metadata for a specific file (omits storagePath)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + file_id: z.string().describe("File ID"), + }, + async ({ space, token, file_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + for (const docId of findFilesDocIds(syncServer, space)) { + const doc = syncServer.getDoc(docId); + const file = doc?.files?.[file_id]; + if (file) { + // Omit storagePath and fileHash for security + const { storagePath, fileHash, ...safe } = file; + return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] }; + } + } + + return { content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }] }; + }, + ); + + server.tool( + "rfiles_list_cards", + "List memory cards (knowledge cards) in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by card type"), + search: z.string().optional().describe("Search in title/body/tags"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, type, search, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findFilesDocIds(syncServer, space); + let cards: Array<{ + id: string; title: string; body: string; cardType: string | null; + tags: string[]; position: number; createdAt: number; updatedAt: number; + }> = []; + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.memoryCards) continue; + for (const c of Object.values(doc.memoryCards)) { + cards.push({ + id: c.id, + title: c.title, + body: c.body.slice(0, 500), + cardType: c.cardType, + tags: c.tags, + position: c.position, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + }); + } + } + + if (type) cards = cards.filter(c => c.cardType === type); + if (search) { + const q = search.toLowerCase(); + cards = cards.filter(c => + c.title.toLowerCase().includes(q) || + c.body.toLowerCase().includes(q) || + c.tags.some(t => t.toLowerCase().includes(q)), + ); + } + + cards.sort((a, b) => b.updatedAt - a.updatedAt); + cards = cards.slice(0, limit || 50); + + return { content: [{ type: "text", text: JSON.stringify(cards, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rflows.ts b/server/mcp-tools/rflows.ts new file mode 100644 index 00000000..be056b9b --- /dev/null +++ b/server/mcp-tools/rflows.ts @@ -0,0 +1,80 @@ +/** + * MCP tools for rFlows (financial modeling & budget allocation). + * + * Tools: rflows_list_flows, rflows_get_budget + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { flowsDocId } from "../../modules/rflows/schemas"; +import type { FlowsDoc } from "../../modules/rflows/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerFlowsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rflows_list_flows", + "List canvas flows (financial models) in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(flowsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No flows data found" }) }] }; + + const flows = Object.values(doc.canvasFlows || {}).map(f => ({ + id: f.id, name: f.name, + nodeCount: f.nodes?.length ?? 0, + createdBy: f.createdBy, + createdAt: f.createdAt, updatedAt: f.updatedAt, + })); + + const mortgageCount = Object.keys(doc.mortgagePositions || {}).length; + const reinvestmentCount = Object.keys(doc.reinvestmentPositions || {}).length; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + flows, + activeFlowId: doc.activeFlowId, + mortgagePositionCount: mortgageCount, + reinvestmentPositionCount: reinvestmentCount, + budgetTotalAmount: doc.budgetTotalAmount, + }, null, 2), + }], + }; + }, + ); + + server.tool( + "rflows_get_budget", + "Get budget segments and participant allocations", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(flowsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No flows data found" }) }] }; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + totalAmount: doc.budgetTotalAmount, + segments: doc.budgetSegments, + allocations: doc.budgetAllocations, + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rforum.ts b/server/mcp-tools/rforum.ts new file mode 100644 index 00000000..3e174884 --- /dev/null +++ b/server/mcp-tools/rforum.ts @@ -0,0 +1,70 @@ +/** + * MCP tools for rForum (Discourse instance provisioning). + * Global-scoped (not per-space). + * + * Tools: rforum_list_instances, rforum_get_instance + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { FORUM_DOC_ID } from "../../modules/rforum/schemas"; +import type { ForumDoc } from "../../modules/rforum/schemas"; +import { verifyToken } from "../auth"; + +export function registerForumTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rforum_list_instances", + "List Discourse forum instances (requires auth — global scope)", + { + token: z.string().describe("JWT auth token (required — admin data)"), + status: z.string().optional().describe("Filter by status (pending, active, error, etc.)"), + }, + async ({ token, status }) => { + try { + await verifyToken(token); + } catch { + return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid or expired token" }) }], isError: true }; + } + + const doc = syncServer.getDoc(FORUM_DOC_ID); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No forum data found" }) }] }; + + let instances = Object.values(doc.instances || {}); + if (status) instances = instances.filter(i => i.status === status); + + const summary = instances.map(i => ({ + id: i.id, name: i.name, domain: i.domain, + status: i.status, provider: i.provider, + region: i.region, vpsIp: i.vpsIp, + sslProvisioned: i.sslProvisioned, + createdAt: i.createdAt, provisionedAt: i.provisionedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rforum_get_instance", + "Get full details of a forum instance including provision logs", + { + token: z.string().describe("JWT auth token"), + instance_id: z.string().describe("Instance ID"), + }, + async ({ token, instance_id }) => { + try { + await verifyToken(token); + } catch { + return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid or expired token" }) }], isError: true }; + } + + const doc = syncServer.getDoc(FORUM_DOC_ID); + const instance = doc?.instances?.[instance_id]; + if (!instance) return { content: [{ type: "text", text: JSON.stringify({ error: "Instance not found" }) }] }; + + const logs = doc!.provisionLogs?.[instance_id] || []; + return { content: [{ type: "text", text: JSON.stringify({ instance, provisionLogs: logs }, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rgov.ts b/server/mcp-tools/rgov.ts new file mode 100644 index 00000000..d30cc565 --- /dev/null +++ b/server/mcp-tools/rgov.ts @@ -0,0 +1,81 @@ +/** + * MCP tools for rGov (modular governance decision circuits). + * rGov shapes live in the space's main canvas document, not a separate Automerge doc. + * + * Tools: rgov_list_circuits, rgov_get_circuit + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getDocumentData } from "../community-store"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const GOV_TYPES = [ + "folk-gov-binary", "folk-gov-threshold", "folk-gov-knob", + "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", "folk-gov-conviction", "folk-gov-multisig", + "folk-gov-sankey", +]; + +export function registerGovTools(server: McpServer) { + server.tool( + "rgov_list_circuits", + "List governance circuit shapes on the space canvas", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by gov shape type (e.g. folk-gov-project, folk-gov-threshold)"), + }, + async ({ space, token, type }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docData = getDocumentData(space); + if (!docData?.shapes) return { content: [{ type: "text" as const, text: JSON.stringify({ shapes: [] }) }] }; + + let govShapes = Object.values(docData.shapes) + .filter((s: any) => !s.forgotten && GOV_TYPES.includes(s.type)); + + if (type) govShapes = govShapes.filter((s: any) => s.type === type); + + const summary = govShapes.map((s: any) => ({ + id: s.id, type: s.type, title: s.title, + status: s.status, x: s.x, y: s.y, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rgov_get_circuit", + "Get full details of a governance shape by ID", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + shape_id: z.string().describe("Shape ID"), + }, + async ({ space, token, shape_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docData = getDocumentData(space); + const shape = docData?.shapes?.[shape_id] as any; + if (!shape || !GOV_TYPES.includes(shape.type)) { + return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Gov shape not found" }) }] }; + } + + // Find arrows connected to this shape + const arrows = Object.values(docData!.shapes) + .filter((s: any) => s.type === "folk-arrow" && (s.sourceId === shape_id || s.targetId === shape_id)) + .map((a: any) => ({ id: a.id, sourceId: a.sourceId, targetId: a.targetId })); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ shape, connectedArrows: arrows }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rinbox.ts b/server/mcp-tools/rinbox.ts new file mode 100644 index 00000000..a5f89fea --- /dev/null +++ b/server/mcp-tools/rinbox.ts @@ -0,0 +1,182 @@ +/** + * 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) }] }; + }, + ); +} diff --git a/server/mcp-tools/rmaps.ts b/server/mcp-tools/rmaps.ts new file mode 100644 index 00000000..2f097df7 --- /dev/null +++ b/server/mcp-tools/rmaps.ts @@ -0,0 +1,79 @@ +/** + * MCP tools for rMaps (map annotations, saved routes, meeting points). + * + * Tools: rmaps_list_annotations, rmaps_list_routes, rmaps_list_meeting_points + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { mapsDocId } from "../../modules/rmaps/schemas"; +import type { MapsDoc } from "../../modules/rmaps/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerMapsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rmaps_list_annotations", + "List map annotations (pins, notes, areas) in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by type: pin, note, or area"), + }, + async ({ space, token, type }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(mapsDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ annotations: [] }) }] }; + + let annotations = Object.values(doc.annotations || {}); + if (type) annotations = annotations.filter(a => a.type === type); + + return { content: [{ type: "text" as const, text: JSON.stringify(annotations, null, 2) }] }; + }, + ); + + server.tool( + "rmaps_list_routes", + "List saved routes in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(mapsDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ routes: [] }) }] }; + + const routes = Object.values(doc.savedRoutes || {}).map(r => ({ + id: r.id, name: r.name, + waypointCount: r.waypoints?.length ?? 0, + authorDid: r.authorDid, createdAt: r.createdAt, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(routes, null, 2) }] }; + }, + ); + + server.tool( + "rmaps_list_meeting_points", + "List saved meeting points in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(mapsDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ meetingPoints: [] }) }] }; + + const points = Object.values(doc.savedMeetingPoints || {}); + return { content: [{ type: "text" as const, text: JSON.stringify(points, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rmeets.ts b/server/mcp-tools/rmeets.ts new file mode 100644 index 00000000..507839e6 --- /dev/null +++ b/server/mcp-tools/rmeets.ts @@ -0,0 +1,62 @@ +/** + * MCP tools for rMeets (video meeting scheduling). + * + * Tools: rmeets_list_meetings, rmeets_get_history + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { meetsDocId } from "../../modules/rmeets/schemas"; +import type { MeetsDoc } from "../../modules/rmeets/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerMeetsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rmeets_list_meetings", + "List scheduled meetings in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + upcoming_only: z.boolean().optional().describe("Only future meetings (default true)"), + }, + async ({ space, token, upcoming_only }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(meetsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No meetings data found" }) }] }; + + let meetings = Object.values(doc.meetings || {}); + if (upcoming_only !== false) { + meetings = meetings.filter(m => m.scheduledAt >= Date.now()); + } + meetings.sort((a, b) => a.scheduledAt - b.scheduledAt); + + return { content: [{ type: "text", text: JSON.stringify(meetings, null, 2) }] }; + }, + ); + + server.tool( + "rmeets_get_history", + "Get meeting history with participant counts", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + limit: z.number().optional().describe("Max results (default 20)"), + }, + async ({ space, token, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(meetsDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No meetings data found" }) }] }; + + const history = (doc.meetingHistory || []) + .slice(-(limit || 20)) + .reverse(); + + return { content: [{ type: "text", text: JSON.stringify(history, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rnetwork.ts b/server/mcp-tools/rnetwork.ts new file mode 100644 index 00000000..b75662db --- /dev/null +++ b/server/mcp-tools/rnetwork.ts @@ -0,0 +1,99 @@ +/** + * MCP tools for rNetwork (CRM contacts & relationships). + * + * Tools: rnetwork_list_contacts, rnetwork_get_contact, rnetwork_list_relationships + * Reads LOCAL Automerge doc only (not external Twenty CRM). + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { networkDocId } from "../../modules/rnetwork/schemas"; +import type { NetworkDoc } from "../../modules/rnetwork/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerNetworkTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rnetwork_list_contacts", + "List CRM contacts in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(networkDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No network data found for this space" }) }] }; + } + + const contacts = Object.values(doc.contacts || {}) + .slice(0, limit || 50) + .map(c => ({ + did: c.did, + name: c.name, + role: c.role, + tags: c.tags, + addedAt: c.addedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] }; + }, + ); + + server.tool( + "rnetwork_get_contact", + "Get a specific contact and their relationships", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + did: z.string().describe("Contact DID"), + }, + async ({ space, token, did }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(networkDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No network data found" }) }] }; + } + + const contact = doc.contacts?.[did]; + if (!contact) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Contact not found" }) }] }; + } + + const relationships = Object.values(doc.relationships || {}) + .filter(r => r.fromDid === did || r.toDid === did); + + return { content: [{ type: "text", text: JSON.stringify({ contact, relationships }, null, 2) }] }; + }, + ); + + server.tool( + "rnetwork_list_relationships", + "List all relationships in a space's network", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + limit: z.number().optional().describe("Max results (default 100)"), + }, + async ({ space, token, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(networkDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No network data found" }) }] }; + } + + const relationships = Object.values(doc.relationships || {}) + .slice(0, limit || 100); + + return { content: [{ type: "text", text: JSON.stringify(relationships, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rnotes.ts b/server/mcp-tools/rnotes.ts new file mode 100644 index 00000000..9cb72e29 --- /dev/null +++ b/server/mcp-tools/rnotes.ts @@ -0,0 +1,232 @@ +/** + * MCP tools for rNotes (notebooks & notes). + * + * Tools: rnotes_list_notebooks, rnotes_list_notes, rnotes_get_note, + * rnotes_create_note, rnotes_update_note + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { notebookDocId, createNoteItem } from "../../modules/rnotes/schemas"; +import type { NotebookDoc, NoteItem } from "../../modules/rnotes/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const NOTEBOOK_PREFIX = ":notes:notebooks:"; + +/** Find all notebook docIds for a space. */ +function findNotebookDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${NOTEBOOK_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerNotesTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rnotes_list_notebooks", + "List all notebooks in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findNotebookDocIds(syncServer, space); + const notebooks = []; + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.notebook) continue; + notebooks.push({ + id: doc.notebook.id, + title: doc.notebook.title, + slug: doc.notebook.slug, + description: doc.notebook.description, + noteCount: Object.keys(doc.items || {}).length, + createdAt: doc.notebook.createdAt, + updatedAt: doc.notebook.updatedAt, + }); + } + return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] }; + }, + ); + + server.tool( + "rnotes_list_notes", + "List notes, optionally filtered by notebook, search text, or tags", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + notebook_id: z.string().optional().describe("Filter by notebook ID"), + search: z.string().optional().describe("Search in title/content"), + limit: z.number().optional().describe("Max results (default 50)"), + tags: z.array(z.string()).optional().describe("Filter by tags"), + }, + async ({ space, token, notebook_id, search, limit, tags }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = notebook_id + ? [notebookDocId(space, notebook_id)] + : findNotebookDocIds(syncServer, space); + + let notes: Array = []; + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.items) continue; + const nbTitle = doc.notebook?.title || "Untitled"; + for (const note of Object.values(doc.items)) { + notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle }); + } + } + + if (search) { + const q = search.toLowerCase(); + notes = notes.filter(n => + n.title.toLowerCase().includes(q) || + (n.contentPlain && n.contentPlain.toLowerCase().includes(q)), + ); + } + + if (tags && tags.length > 0) { + notes = notes.filter(n => + n.tags && tags.some(t => n.tags.includes(t)), + ); + } + + notes.sort((a, b) => b.updatedAt - a.updatedAt); + const maxResults = limit || 50; + notes = notes.slice(0, maxResults); + + const summary = notes.map(n => ({ + id: n.id, + notebookId: n.notebookId, + notebookTitle: n.notebookTitle, + title: n.title, + type: n.type, + tags: n.tags, + isPinned: n.isPinned, + contentPreview: (n.contentPlain || "").slice(0, 200), + createdAt: n.createdAt, + updatedAt: n.updatedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rnotes_get_note", + "Get the full content of a specific note", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + note_id: z.string().describe("Note ID"), + notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), + }, + async ({ space, token, note_id, notebook_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + if (notebook_id) { + const doc = syncServer.getDoc(notebookDocId(space, notebook_id)); + const note = doc?.items?.[note_id]; + if (note) { + return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; + } + } + + for (const docId of findNotebookDocIds(syncServer, space)) { + const doc = syncServer.getDoc(docId); + const note = doc?.items?.[note_id]; + if (note) { + return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; + } + } + + return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }] }; + }, + ); + + server.tool( + "rnotes_create_note", + "Create a new note in a notebook (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + notebook_id: z.string().describe("Target notebook ID"), + title: z.string().describe("Note title"), + content: z.string().optional().describe("Note content (plain text or HTML)"), + tags: z.array(z.string()).optional().describe("Note tags"), + }, + async ({ space, token, notebook_id, title, content, tags }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = notebookDocId(space, notebook_id); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Notebook not found" }) }], isError: true }; + } + + const noteId = `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const noteItem = createNoteItem(noteId, notebook_id, title, { + content: content || "", + contentPlain: content || "", + contentFormat: "html", + tags: tags || [], + }); + + syncServer.changeDoc(docId, `Create note ${title}`, (d) => { + if (!d.items) (d as any).items = {}; + d.items[noteId] = noteItem; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: noteId, created: true }) }] }; + }, + ); + + server.tool( + "rnotes_update_note", + "Update an existing note (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + note_id: z.string().describe("Note ID"), + notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), + title: z.string().optional().describe("New title"), + content: z.string().optional().describe("New content"), + tags: z.array(z.string()).optional().describe("New tags"), + is_pinned: z.boolean().optional().describe("Pin/unpin note"), + }, + async ({ space, token, note_id, notebook_id, ...updates }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = notebook_id + ? [notebookDocId(space, notebook_id)] + : findNotebookDocIds(syncServer, space); + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.items?.[note_id]) continue; + + syncServer.changeDoc(docId, `Update note ${note_id}`, (d) => { + const n = d.items[note_id]; + if (updates.title !== undefined) n.title = updates.title; + if (updates.content !== undefined) { + n.content = updates.content; + n.contentPlain = updates.content; + } + if (updates.tags !== undefined) n.tags = updates.tags; + if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned; + n.updatedAt = Date.now(); + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: note_id, updated: true }) }] }; + } + + return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }], isError: true }; + }, + ); +} diff --git a/server/mcp-tools/rphotos.ts b/server/mcp-tools/rphotos.ts new file mode 100644 index 00000000..61559927 --- /dev/null +++ b/server/mcp-tools/rphotos.ts @@ -0,0 +1,55 @@ +/** + * MCP tools for rPhotos (shared albums & annotations). + * + * Tools: rphotos_list_albums, rphotos_list_annotations + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { photosDocId } from "../../modules/rphotos/schemas"; +import type { PhotosDoc } from "../../modules/rphotos/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerPhotosTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rphotos_list_albums", + "List shared photo albums in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(photosDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No photos data found" }) }] }; + + const albums = Object.values(doc.sharedAlbums || {}); + return { content: [{ type: "text", text: JSON.stringify(albums, null, 2) }] }; + }, + ); + + server.tool( + "rphotos_list_annotations", + "List photo annotations (notes on photos)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + asset_id: z.string().optional().describe("Filter by asset ID"), + }, + async ({ space, token, asset_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(photosDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No photos data found" }) }] }; + + let annotations = Object.values(doc.annotations || {}); + if (asset_id) annotations = annotations.filter(a => a.assetId === asset_id); + + return { content: [{ type: "text", text: JSON.stringify(annotations, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rpubs.ts b/server/mcp-tools/rpubs.ts new file mode 100644 index 00000000..e5796dec --- /dev/null +++ b/server/mcp-tools/rpubs.ts @@ -0,0 +1,81 @@ +/** + * MCP tools for rPubs (publication drafts). + * + * Tools: rpubs_list_drafts, rpubs_get_draft + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import type { PubsDoc } from "../../modules/rpubs/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const PUBS_PREFIX = ":pubs:drafts:"; + +function findPubsDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${PUBS_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerPubsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rpubs_list_drafts", + "List publication drafts in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findPubsDocIds(syncServer, space); + const drafts = []; + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.draft) continue; + drafts.push({ + id: doc.draft.id, + title: doc.draft.title, + author: doc.draft.author, + format: doc.draft.format, + contentPreview: (doc.content || "").slice(0, 200), + createdAt: doc.draft.createdAt, + updatedAt: doc.draft.updatedAt, + }); + } + + return { content: [{ type: "text", text: JSON.stringify(drafts, null, 2) }] }; + }, + ); + + server.tool( + "rpubs_get_draft", + "Get full publication draft content", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + draft_id: z.string().describe("Draft ID"), + }, + async ({ space, token, draft_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${PUBS_PREFIX}${draft_id}`; + const doc = syncServer.getDoc(docId); + if (!doc?.draft) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Draft not found" }) }] }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + draft: doc.draft, + content: doc.content || "", + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rschedule.ts b/server/mcp-tools/rschedule.ts new file mode 100644 index 00000000..4fa7cdd7 --- /dev/null +++ b/server/mcp-tools/rschedule.ts @@ -0,0 +1,190 @@ +/** + * MCP tools for rSchedule (cron jobs, reminders, workflows). + * + * Tools: rschedule_list_jobs, rschedule_list_reminders, + * rschedule_list_workflows, rschedule_create_reminder + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { scheduleDocId } from "../../modules/rschedule/schemas"; +import type { ScheduleDoc } from "../../modules/rschedule/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerScheduleTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rschedule_list_jobs", + "List cron/scheduled jobs in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + enabled_only: z.boolean().optional().describe("Only show enabled jobs (default false)"), + }, + async ({ space, token, enabled_only }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(scheduleDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; + } + + let jobs = Object.values(doc.jobs || {}); + if (enabled_only) jobs = jobs.filter(j => j.enabled); + + const summary = jobs.map(j => ({ + id: j.id, + name: j.name, + description: j.description, + enabled: j.enabled, + cronExpression: j.cronExpression, + timezone: j.timezone, + actionType: j.actionType, + lastRunAt: j.lastRunAt, + lastRunStatus: j.lastRunStatus, + nextRunAt: j.nextRunAt, + runCount: j.runCount, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rschedule_list_reminders", + "List reminders in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + completed: z.boolean().optional().describe("Filter by completed status"), + upcoming_days: z.number().optional().describe("Only show reminders firing in next N days"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, completed, upcoming_days, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(scheduleDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; + } + + let reminders = Object.values(doc.reminders || {}); + + if (completed !== undefined) { + reminders = reminders.filter(r => r.completed === completed); + } + + if (upcoming_days) { + const now = Date.now(); + const cutoff = now + upcoming_days * 86400000; + reminders = reminders.filter(r => r.remindAt >= now && r.remindAt <= cutoff); + } + + reminders.sort((a, b) => a.remindAt - b.remindAt); + reminders = reminders.slice(0, limit || 50); + + const summary = reminders.map(r => ({ + id: r.id, + title: r.title, + description: r.description, + remindAt: r.remindAt, + allDay: r.allDay, + completed: r.completed, + notified: r.notified, + sourceModule: r.sourceModule, + sourceLabel: r.sourceLabel, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rschedule_list_workflows", + "List automation workflows in a space (summaries only, omits node/edge graph)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(scheduleDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; + } + + const workflows = Object.values(doc.workflows || {}).map(w => ({ + id: w.id, + name: w.name, + enabled: w.enabled, + nodeCount: w.nodes?.length ?? 0, + edgeCount: w.edges?.length ?? 0, + lastRunAt: w.lastRunAt, + lastRunStatus: w.lastRunStatus, + runCount: w.runCount, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(workflows, null, 2) }] }; + }, + ); + + server.tool( + "rschedule_create_reminder", + "Create a new reminder (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + title: z.string().describe("Reminder title"), + remind_at: z.number().describe("When to fire (epoch ms)"), + description: z.string().optional().describe("Reminder description"), + all_day: z.boolean().optional().describe("All-day reminder"), + source_module: z.string().optional().describe("Originating module (e.g. 'rtasks')"), + source_label: z.string().optional().describe("Source display label"), + }, + async ({ space, token, title, remind_at, description, all_day, source_module, source_label }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = scheduleDocId(space); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }], isError: true }; + } + + const reminderId = `rem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const now = Date.now(); + syncServer.changeDoc(docId, `Create reminder ${title}`, (d) => { + if (!d.reminders) (d as any).reminders = {}; + d.reminders[reminderId] = { + id: reminderId, + title, + description: description || "", + remindAt: remind_at, + allDay: all_day || false, + timezone: "UTC", + notifyEmail: null, + notified: false, + completed: false, + sourceModule: source_module || null, + sourceEntityId: null, + sourceLabel: source_label || null, + sourceColor: null, + cronExpression: null, + calendarEventId: null, + createdBy: access.claims?.did ?? "", + createdAt: now, + updatedAt: now, + } as any; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: reminderId, created: true }) }] }; + }, + ); +} diff --git a/server/mcp-tools/rsheet.ts b/server/mcp-tools/rsheet.ts new file mode 100644 index 00000000..7df5c8dc --- /dev/null +++ b/server/mcp-tools/rsheet.ts @@ -0,0 +1,78 @@ +/** + * MCP tools for rSheet (collaborative spreadsheets). + * Multi-doc: {space}:sheet:sheets:{sheetId} + * + * Tools: rsheet_list_sheets, rsheet_get_sheet + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { sheetDocId } from "../../modules/rsheet/schemas"; +import type { SheetDoc } from "../../modules/rsheet/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerSheetTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rsheet_list_sheets", + "List spreadsheets in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const prefix = `${space}:sheet:sheets:`; + const sheets: { id: string; name: string; description: string; cellCount: number; columnCount: number; createdBy: string | null; createdAt: number; updatedAt: number }[] = []; + + for (const docId of syncServer.listDocs()) { + if (!docId.startsWith(prefix)) continue; + const doc = syncServer.getDoc(docId); + if (!doc?.sheet) continue; + sheets.push({ + id: doc.sheet.id, + name: doc.sheet.name, + description: doc.sheet.description, + cellCount: Object.keys(doc.cells || {}).length, + columnCount: Object.keys(doc.columns || {}).length, + createdBy: doc.sheet.createdBy, + createdAt: doc.sheet.createdAt, + updatedAt: doc.sheet.updatedAt, + }); + } + + return { content: [{ type: "text" as const, text: JSON.stringify(sheets, null, 2) }] }; + }, + ); + + server.tool( + "rsheet_get_sheet", + "Get full sheet data (meta, columns, cells)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + sheet_id: z.string().describe("Sheet ID"), + }, + async ({ space, token, sheet_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(sheetDocId(space, sheet_id)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Sheet not found" }) }] }; + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + sheet: doc.sheet, + columns: doc.columns, + rows: doc.rows, + cells: doc.cells, + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rsocials.ts b/server/mcp-tools/rsocials.ts new file mode 100644 index 00000000..08a42837 --- /dev/null +++ b/server/mcp-tools/rsocials.ts @@ -0,0 +1,144 @@ +/** + * MCP tools for rSocials (campaigns & social threads). + * + * Tools: rsocials_list_campaigns, rsocials_get_campaign, + * rsocials_list_threads, rsocials_create_thread + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { socialsDocId } from "../../modules/rsocials/schemas"; +import type { SocialsDoc } from "../../modules/rsocials/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerSocialsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rsocials_list_campaigns", + "List social media campaigns in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(socialsDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found for this space" }) }] }; + } + + const campaigns = Object.values(doc.campaigns || {}).map(c => ({ + id: c.id, + title: c.title, + description: c.description, + platforms: c.platforms, + postCount: c.posts?.length ?? 0, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(campaigns, null, 2) }] }; + }, + ); + + server.tool( + "rsocials_get_campaign", + "Get full details of a specific campaign including posts", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + campaign_id: z.string().describe("Campaign ID"), + }, + async ({ space, token, campaign_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(socialsDocId(space)); + const campaign = doc?.campaigns?.[campaign_id]; + if (!campaign) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Campaign not found" }) }] }; + } + + return { content: [{ type: "text", text: JSON.stringify(campaign, null, 2) }] }; + }, + ); + + server.tool( + "rsocials_list_threads", + "List social threads in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(socialsDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found" }) }] }; + } + + const threads = Object.values(doc.threads || {}) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit || 50) + .map(t => ({ + id: t.id, + name: t.name, + handle: t.handle, + title: t.title, + tweetCount: t.tweets?.length ?? 0, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(threads, null, 2) }] }; + }, + ); + + server.tool( + "rsocials_create_thread", + "Create a new social thread (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + name: z.string().describe("Author display name"), + handle: z.string().describe("Author handle"), + title: z.string().describe("Thread title"), + tweets: z.array(z.string()).describe("Tweet texts in order"), + }, + async ({ space, token, name, handle, title, tweets }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = socialsDocId(space); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found" }) }], isError: true }; + } + + const threadId = `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + + syncServer.changeDoc(docId, `Create thread ${title}`, (d) => { + if (!d.threads) (d as any).threads = {}; + d.threads[threadId] = { + id: threadId, + name, + handle, + title, + tweets, + imageUrl: null, + tweetImages: null, + createdAt: now, + updatedAt: now, + }; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: threadId, created: true }) }] }; + }, + ); +} diff --git a/server/mcp-tools/rsplat.ts b/server/mcp-tools/rsplat.ts new file mode 100644 index 00000000..3cbee300 --- /dev/null +++ b/server/mcp-tools/rsplat.ts @@ -0,0 +1,74 @@ +/** + * MCP tools for rSplat (3D gaussian splat scenes). + * + * Tools: rsplat_list_scenes, rsplat_get_scene + * Omits filePath from responses. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { splatScenesDocId } from "../../modules/rsplat/schemas"; +import type { SplatScenesDoc } from "../../modules/rsplat/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerSplatTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rsplat_list_scenes", + "List 3D gaussian splat scenes in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + search: z.string().optional().describe("Search in title/tags"), + }, + async ({ space, token, search }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(splatScenesDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No splat data found" }) }] }; + + let items = Object.values(doc.items || {}); + if (search) { + const q = search.toLowerCase(); + items = items.filter(i => + i.title.toLowerCase().includes(q) || + i.tags.some(t => t.toLowerCase().includes(q)), + ); + } + + const summary = items.map(i => ({ + id: i.id, slug: i.slug, title: i.title, + description: (i.description || "").slice(0, 200), + fileFormat: i.fileFormat, fileSizeBytes: i.fileSizeBytes, + tags: i.tags, status: i.status, + processingStatus: i.processingStatus, + viewCount: i.viewCount, sourceFileCount: i.sourceFileCount, + thumbnailUrl: i.thumbnailUrl, createdAt: i.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rsplat_get_scene", + "Get full scene metadata (omits filePath)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + scene_id: z.string().describe("Scene ID"), + }, + async ({ space, token, scene_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(splatScenesDocId(space)); + const scene = doc?.items?.[scene_id]; + if (!scene) return { content: [{ type: "text", text: JSON.stringify({ error: "Scene not found" }) }] }; + + const { filePath, ...safe } = scene; + return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rswag.ts b/server/mcp-tools/rswag.ts new file mode 100644 index 00000000..16c70be9 --- /dev/null +++ b/server/mcp-tools/rswag.ts @@ -0,0 +1,64 @@ +/** + * MCP tools for rSwag (print-on-demand designs). + * + * Tools: rswag_list_designs, rswag_get_design + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { swagDocId } from "../../modules/rswag/schemas"; +import type { SwagDoc } from "../../modules/rswag/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerSwagTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rswag_list_designs", + "List print-on-demand designs in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (draft, active, paused, removed)"), + }, + async ({ space, token, status }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(swagDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No swag data found" }) }] }; + + let designs = Object.values(doc.designs || {}); + if (status) designs = designs.filter(d => d.status === status); + + const summary = designs.map(d => ({ + id: d.id, title: d.title, productType: d.productType, + source: d.source, status: d.status, + imageUrl: d.imageUrl, tags: d.tags, + productCount: d.products?.length ?? 0, + createdAt: d.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rswag_get_design", + "Get full design details with product variants and pricing", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + design_id: z.string().describe("Design ID"), + }, + async ({ space, token, design_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(swagDocId(space)); + const design = doc?.designs?.[design_id]; + if (!design) return { content: [{ type: "text", text: JSON.stringify({ error: "Design not found" }) }] }; + + return { content: [{ type: "text", text: JSON.stringify(design, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rtasks.ts b/server/mcp-tools/rtasks.ts new file mode 100644 index 00000000..3cc00a96 --- /dev/null +++ b/server/mcp-tools/rtasks.ts @@ -0,0 +1,236 @@ +/** + * MCP tools for rTasks (task boards). + * + * Tools: rtasks_list_boards, rtasks_list_tasks, rtasks_get_task, + * rtasks_create_task, rtasks_update_task + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { boardDocId, createTaskItem } from "../../modules/rtasks/schemas"; +import type { BoardDoc, TaskItem } from "../../modules/rtasks/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const BOARD_PREFIX = ":tasks:boards:"; + +/** Find all board docIds for a space. */ +function findBoardDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${BOARD_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerTasksTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rtasks_list_boards", + "List all task boards in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findBoardDocIds(syncServer, space); + const boards = []; + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.board) continue; + boards.push({ + id: doc.board.id, + name: doc.board.name, + slug: doc.board.slug, + description: doc.board.description, + statuses: doc.board.statuses, + labels: doc.board.labels, + taskCount: Object.keys(doc.tasks || {}).length, + createdAt: doc.board.createdAt, + }); + } + return { content: [{ type: "text", text: JSON.stringify(boards, null, 2) }] }; + }, + ); + + server.tool( + "rtasks_list_tasks", + "List tasks on a board, optionally filtered by status, priority, or search text", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + board_slug: z.string().describe("Board slug or ID"), + status: z.string().optional().describe("Filter by status (e.g. TODO, IN_PROGRESS, DONE)"), + priority: z.string().optional().describe("Filter by priority"), + search: z.string().optional().describe("Search in title/description"), + exclude_done: z.boolean().optional().describe("Exclude DONE tasks (default false)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, board_slug, status, priority, search, exclude_done, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = boardDocId(space, board_slug); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }] }; + } + + let tasks = Object.values(doc.tasks || {}); + + if (status) tasks = tasks.filter(t => t.status === status); + if (priority) tasks = tasks.filter(t => t.priority === priority); + if (exclude_done) tasks = tasks.filter(t => t.status !== "DONE"); + + if (search) { + const q = search.toLowerCase(); + tasks = tasks.filter(t => + t.title.toLowerCase().includes(q) || + (t.description && t.description.toLowerCase().includes(q)), + ); + } + + tasks.sort((a, b) => (a.sortOrder - b.sortOrder) || (a.createdAt - b.createdAt)); + const maxResults = limit || 50; + tasks = tasks.slice(0, maxResults); + + const summary = tasks.map(t => ({ + id: t.id, + title: t.title, + status: t.status, + priority: t.priority, + labels: t.labels, + assigneeId: t.assigneeId, + dueDate: t.dueDate, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rtasks_get_task", + "Get full details of a specific task", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + task_id: z.string().describe("Task ID"), + board_slug: z.string().optional().describe("Board slug (speeds up lookup)"), + }, + async ({ space, token, task_id, board_slug }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + if (board_slug) { + const doc = syncServer.getDoc(boardDocId(space, board_slug)); + const task = doc?.tasks?.[task_id]; + if (task) { + return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; + } + } + + for (const docId of findBoardDocIds(syncServer, space)) { + const doc = syncServer.getDoc(docId); + const task = doc?.tasks?.[task_id]; + if (task) { + return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; + } + } + + return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] }; + }, + ); + + server.tool( + "rtasks_create_task", + "Create a new task on a board (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + board_slug: z.string().describe("Board slug or ID"), + title: z.string().describe("Task title"), + description: z.string().optional().describe("Task description"), + status: z.string().optional().describe("Initial status (default: TODO)"), + priority: z.string().optional().describe("Priority level"), + labels: z.array(z.string()).optional().describe("Labels/tags"), + due_date: z.number().optional().describe("Due date (epoch ms)"), + assignee_id: z.string().optional().describe("Assignee DID"), + }, + async ({ space, token, board_slug, title, description, status, priority, labels, due_date, assignee_id }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = boardDocId(space, board_slug); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }], isError: true }; + } + + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const taskItem = createTaskItem(taskId, space, title, { + description: description || "", + status: status || "TODO", + priority: priority || null, + labels: labels || [], + dueDate: due_date || null, + assigneeId: assignee_id || null, + createdBy: (access.claims?.did as string) ?? null, + }); + + syncServer.changeDoc(docId, `Create task ${title}`, (d) => { + if (!d.tasks) (d as any).tasks = {}; + d.tasks[taskId] = taskItem; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: taskId, created: true }) }] }; + }, + ); + + server.tool( + "rtasks_update_task", + "Update an existing task (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + task_id: z.string().describe("Task ID"), + board_slug: z.string().optional().describe("Board slug (speeds up lookup)"), + title: z.string().optional().describe("New title"), + description: z.string().optional().describe("New description"), + status: z.string().optional().describe("New status"), + priority: z.string().optional().describe("New priority"), + labels: z.array(z.string()).optional().describe("New labels"), + due_date: z.number().optional().describe("New due date (epoch ms)"), + assignee_id: z.string().optional().describe("New assignee DID"), + }, + async ({ space, token, task_id, board_slug, ...updates }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = board_slug + ? [boardDocId(space, board_slug)] + : findBoardDocIds(syncServer, space); + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.tasks?.[task_id]) continue; + + syncServer.changeDoc(docId, `Update task ${task_id}`, (d) => { + const t = d.tasks[task_id]; + if (updates.title !== undefined) t.title = updates.title; + if (updates.description !== undefined) t.description = updates.description; + if (updates.status !== undefined) t.status = updates.status; + if (updates.priority !== undefined) t.priority = updates.priority; + if (updates.labels !== undefined) t.labels = updates.labels; + if (updates.due_date !== undefined) t.dueDate = updates.due_date; + if (updates.assignee_id !== undefined) t.assigneeId = updates.assignee_id; + t.updatedAt = Date.now(); + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: task_id, updated: true }) }] }; + } + + return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }], isError: true }; + }, + ); +} diff --git a/server/mcp-tools/rtime.ts b/server/mcp-tools/rtime.ts new file mode 100644 index 00000000..d2a08e13 --- /dev/null +++ b/server/mcp-tools/rtime.ts @@ -0,0 +1,176 @@ +/** + * MCP tools for rTime (commitments, tasks, external time logs). + * + * Tools: rtime_list_commitments, rtime_list_tasks, + * rtime_list_time_logs, rtime_create_commitment + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { + commitmentsDocId, + tasksDocId, + externalTimeLogsDocId, +} from "../../modules/rtime/schemas"; +import type { + CommitmentsDoc, + TasksDoc, + ExternalTimeLogsDoc, + Skill, +} from "../../modules/rtime/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const VALID_SKILLS: Skill[] = ["facilitation", "design", "tech", "outreach", "logistics"]; + +export function registerTimeTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rtime_list_commitments", + "List resource commitments in a space (time pledges by members)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + skill: z.string().optional().describe("Filter by skill (facilitation, design, tech, outreach, logistics)"), + status: z.string().optional().describe("Filter by status (active, matched, settled, withdrawn)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, skill, status, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(commitmentsDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }] }; + } + + let items = Object.values(doc.items || {}); + if (skill) items = items.filter(c => c.skill === skill); + if (status) items = items.filter(c => (c.status || "active") === status); + + items.sort((a, b) => b.createdAt - a.createdAt); + items = items.slice(0, limit || 50); + + const summary = items.map(c => ({ + id: c.id, + memberName: c.memberName, + hours: c.hours, + skill: c.skill, + desc: c.desc, + status: c.status || "active", + createdAt: c.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rtime_list_tasks", + "List rTime tasks with their needs maps", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(tasksDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] }; + } + + const tasks = Object.values(doc.tasks || {}) + .slice(0, limit || 50) + .map(t => ({ + id: t.id, + name: t.name, + description: t.description, + needs: t.needs, + })); + + return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] }; + }, + ); + + server.tool( + "rtime_list_time_logs", + "List external time logs (imported from backlog-md)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (pending, commitment_created, settled)"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, status, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(externalTimeLogsDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No time logs found" }) }] }; + } + + let logs = Object.values(doc.logs || {}); + if (status) logs = logs.filter(l => l.status === status); + + logs.sort((a, b) => b.loggedAt - a.loggedAt); + logs = logs.slice(0, limit || 50); + + const summary = logs.map(l => ({ + id: l.id, + backlogTaskTitle: l.backlogTaskTitle, + memberName: l.memberName, + hours: l.hours, + skill: l.skill, + status: l.status, + loggedAt: l.loggedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rtime_create_commitment", + "Create a new time commitment (requires auth token + space membership)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + member_name: z.string().describe("Name of the committing member"), + hours: z.number().min(1).max(10).describe("Hours committed (1-10)"), + skill: z.enum(["facilitation", "design", "tech", "outreach", "logistics"]).describe("Skill type"), + desc: z.string().describe("Description of the commitment"), + }, + async ({ space, token, member_name, hours, skill, desc }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = commitmentsDocId(space); + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }], isError: true }; + } + + const commitmentId = `cmt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = Date.now(); + + syncServer.changeDoc(docId, `Create commitment by ${member_name}`, (d) => { + if (!d.items) (d as any).items = {}; + d.items[commitmentId] = { + id: commitmentId, + memberName: member_name, + hours, + skill: skill as Skill, + desc, + createdAt: now, + status: "active", + ownerDid: (access.claims?.did as string) ?? undefined, + }; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: commitmentId, created: true }) }] }; + }, + ); +} diff --git a/server/mcp-tools/rtrips.ts b/server/mcp-tools/rtrips.ts new file mode 100644 index 00000000..18142052 --- /dev/null +++ b/server/mcp-tools/rtrips.ts @@ -0,0 +1,156 @@ +/** + * MCP tools for rTrips (travel planning). + * + * Tools: rtrips_list_trips, rtrips_get_trip, rtrips_list_itinerary, rtrips_list_expenses + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import type { TripDoc } from "../../modules/rtrips/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const TRIP_PREFIX = ":trips:trips:"; + +function findTripDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${TRIP_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerTripsTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rtrips_list_trips", + "List all trips in a space with budget summaries", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findTripDocIds(syncServer, space); + const trips = []; + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.trip) continue; + const t = doc.trip; + trips.push({ + id: t.id, + title: t.title, + slug: t.slug, + status: t.status, + startDate: t.startDate, + endDate: t.endDate, + budgetTotal: t.budgetTotal, + budgetCurrency: t.budgetCurrency, + destinationCount: Object.keys(doc.destinations || {}).length, + bookingCount: Object.keys(doc.bookings || {}).length, + expenseCount: Object.keys(doc.expenses || {}).length, + createdAt: t.createdAt, + }); + } + return { content: [{ type: "text", text: JSON.stringify(trips, null, 2) }] }; + }, + ); + + server.tool( + "rtrips_get_trip", + "Get full trip details with destinations and bookings", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + trip_id: z.string().describe("Trip ID"), + }, + async ({ space, token, trip_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${TRIP_PREFIX}${trip_id}`; + const doc = syncServer.getDoc(docId); + if (!doc?.trip) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Trip not found" }) }] }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + trip: doc.trip, + destinations: Object.values(doc.destinations || {}), + bookings: Object.values(doc.bookings || {}), + packingItemCount: Object.keys(doc.packingItems || {}).length, + expenseTotal: Object.values(doc.expenses || {}).reduce((sum, e) => sum + (e.amount || 0), 0), + }, null, 2), + }], + }; + }, + ); + + server.tool( + "rtrips_list_itinerary", + "List itinerary items for a trip, sorted by date", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + trip_id: z.string().describe("Trip ID"), + date: z.string().optional().describe("Filter by date (YYYY-MM-DD)"), + }, + async ({ space, token, trip_id, date }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${TRIP_PREFIX}${trip_id}`; + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Trip not found" }) }] }; + } + + let items = Object.values(doc.itinerary || {}); + if (date) items = items.filter(i => i.date === date); + items.sort((a, b) => (a.date || "").localeCompare(b.date || "") || a.sortOrder - b.sortOrder); + + return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] }; + }, + ); + + server.tool( + "rtrips_list_expenses", + "List trip expenses with totals by category", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + trip_id: z.string().describe("Trip ID"), + }, + async ({ space, token, trip_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${TRIP_PREFIX}${trip_id}`; + const doc = syncServer.getDoc(docId); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Trip not found" }) }] }; + } + + const expenses = Object.values(doc.expenses || {}); + const total = expenses.reduce((sum, e) => sum + (e.amount || 0), 0); + const byCategory: Record = {}; + for (const e of expenses) { + const cat = e.category || "uncategorized"; + byCategory[cat] = (byCategory[cat] || 0) + (e.amount || 0); + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + expenses, + total, + currency: doc.trip?.budgetCurrency || null, + byCategory, + }, null, 2), + }], + }; + }, + ); +} diff --git a/server/mcp-tools/rtube.ts b/server/mcp-tools/rtube.ts new file mode 100644 index 00000000..2eda8e92 --- /dev/null +++ b/server/mcp-tools/rtube.ts @@ -0,0 +1,59 @@ +/** + * MCP tools for rTube (video playlists & watch parties). + * + * Tools: rtube_list_playlists, rtube_get_watch_party + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { tubeDocId } from "../../modules/rtube/schemas"; +import type { TubeDoc } from "../../modules/rtube/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerTubeTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rtube_list_playlists", + "List video playlists in a space", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(tubeDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No tube data found" }) }] }; + + const playlists = Object.values(doc.playlists || {}).map(p => ({ + id: p.id, name: p.name, + entryCount: p.entries?.length ?? 0, + createdBy: p.createdBy, + createdAt: p.createdAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(playlists, null, 2) }] }; + }, + ); + + server.tool( + "rtube_get_watch_party", + "Get active watch party status", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(tubeDocId(space)); + if (!doc?.watchParty) { + return { content: [{ type: "text", text: JSON.stringify({ active: false }) }] }; + } + + return { content: [{ type: "text", text: JSON.stringify({ active: true, ...doc.watchParty }, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rvnb.ts b/server/mcp-tools/rvnb.ts new file mode 100644 index 00000000..1adefb71 --- /dev/null +++ b/server/mcp-tools/rvnb.ts @@ -0,0 +1,104 @@ +/** + * MCP tools for rVnb (community vehicle sharing). + * + * Tools: rvnb_list_vehicles, rvnb_get_vehicle, rvnb_list_rentals + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { vnbDocId } from "../../modules/rvnb/schemas"; +import type { VnbDoc } from "../../modules/rvnb/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerVnbTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rvnb_list_vehicles", + "List vehicle listings (RVs, camper vans, etc.)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + type: z.string().optional().describe("Filter by type (motorhome, camper_van, travel_trailer, skoolie, etc.)"), + active_only: z.boolean().optional().describe("Only active listings (default true)"), + }, + async ({ space, token, type, active_only }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(vnbDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No vnb data found" }) }] }; + + let vehicles = Object.values(doc.vehicles || {}); + if (active_only !== false) vehicles = vehicles.filter(v => v.isActive); + if (type) vehicles = vehicles.filter(v => v.type === type); + + const summary = vehicles.map(v => ({ + id: v.id, ownerName: v.ownerName, title: v.title, + type: v.type, economy: v.economy, + year: v.year, make: v.make, model: v.model, + sleeps: v.sleeps, lengthFeet: v.lengthFeet, + hasSolar: v.hasSolar, hasAC: v.hasAC, hasKitchen: v.hasKitchen, + petFriendly: v.petFriendly, + suggestedAmount: v.suggestedAmount, currency: v.currency, + pickupLocationName: v.pickupLocationName, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.tool( + "rvnb_get_vehicle", + "Get full vehicle details with trip availability windows", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + vehicle_id: z.string().describe("Vehicle ID"), + }, + async ({ space, token, vehicle_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(vnbDocId(space)); + const vehicle = doc?.vehicles?.[vehicle_id]; + if (!vehicle) return { content: [{ type: "text", text: JSON.stringify({ error: "Vehicle not found" }) }] }; + + const availability = Object.values(doc!.availability || {}) + .filter(a => a.vehicleId === vehicle_id); + + return { content: [{ type: "text", text: JSON.stringify({ vehicle, availability }, null, 2) }] }; + }, + ); + + server.tool( + "rvnb_list_rentals", + "List rental requests with status", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status (pending, accepted, declined, completed, etc.)"), + }, + async ({ space, token, status }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(vnbDocId(space)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No vnb data found" }) }] }; + + let rentals = Object.values(doc.rentals || {}); + if (status) rentals = rentals.filter(r => r.status === status); + rentals.sort((a, b) => b.requestedAt - a.requestedAt); + + const summary = rentals.map(r => ({ + id: r.id, vehicleId: r.vehicleId, + renterName: r.renterName, status: r.status, + pickupDate: r.pickupDate, dropoffDate: r.dropoffDate, + estimatedMiles: r.estimatedMiles, + messageCount: r.messages?.length ?? 0, + requestedAt: r.requestedAt, + })); + + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rvote.ts b/server/mcp-tools/rvote.ts new file mode 100644 index 00000000..c6808ff2 --- /dev/null +++ b/server/mcp-tools/rvote.ts @@ -0,0 +1,132 @@ +/** + * MCP tools for rVote (proposals & pairwise ranking). + * + * Tools: rvote_list_proposals, rvote_get_proposal, rvote_get_results + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import type { ProposalDoc } from "../../modules/rvote/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +const PROPOSAL_PREFIX = ":vote:proposals:"; + +function findProposalDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${PROPOSAL_PREFIX}`; + return syncServer.getDocIds().filter(id => id.startsWith(prefix)); +} + +export function registerVoteTools(server: McpServer, syncServer: SyncServer) { + server.tool( + "rvote_list_proposals", + "List governance proposals in a space with Elo rankings and vote tallies", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + status: z.string().optional().describe("Filter by status"), + limit: z.number().optional().describe("Max results (default 50)"), + }, + async ({ space, token, status, limit }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findProposalDocIds(syncServer, space); + let proposals: any[] = []; + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.proposal) continue; + const p = doc.proposal; + proposals.push({ + id: p.id, + title: p.title, + description: (p.description || "").slice(0, 200), + status: p.status, + score: p.score, + elo: doc.pairwise?.elo ?? null, + comparisons: doc.pairwise?.comparisons ?? 0, + finalYes: p.finalYes, + finalNo: p.finalNo, + finalAbstain: p.finalAbstain, + voteCount: Object.keys(doc.votes || {}).length, + votingEndsAt: p.votingEndsAt, + createdAt: p.createdAt, + }); + } + + if (status) proposals = proposals.filter(p => p.status === status); + proposals.sort((a, b) => (b.elo ?? 0) - (a.elo ?? 0)); + proposals = proposals.slice(0, limit || 50); + + return { content: [{ type: "text", text: JSON.stringify(proposals, null, 2) }] }; + }, + ); + + server.tool( + "rvote_get_proposal", + "Get full details of a specific proposal including votes and pairwise data", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + proposal_id: z.string().describe("Proposal ID"), + }, + async ({ space, token, proposal_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docId = `${space}${PROPOSAL_PREFIX}${proposal_id}`; + const doc = syncServer.getDoc(docId); + if (!doc?.proposal) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Proposal not found" }) }] }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ + proposal: doc.proposal, + pairwise: doc.pairwise, + voteCount: Object.keys(doc.votes || {}).length, + finalVoteCount: Object.keys(doc.finalVotes || {}).length, + }, null, 2), + }], + }; + }, + ); + + server.tool( + "rvote_get_results", + "Get vote results summary: Elo rankings across all proposals", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const docIds = findProposalDocIds(syncServer, space); + const rankings = []; + + for (const docId of docIds) { + const doc = syncServer.getDoc(docId); + if (!doc?.proposal) continue; + rankings.push({ + id: doc.proposal.id, + title: doc.proposal.title, + status: doc.proposal.status, + elo: doc.pairwise?.elo ?? 1500, + comparisons: doc.pairwise?.comparisons ?? 0, + wins: doc.pairwise?.wins ?? 0, + finalYes: doc.proposal.finalYes, + finalNo: doc.proposal.finalNo, + finalAbstain: doc.proposal.finalAbstain, + }); + } + + rankings.sort((a, b) => b.elo - a.elo); + return { content: [{ type: "text", text: JSON.stringify(rankings, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/rwallet.ts b/server/mcp-tools/rwallet.ts new file mode 100644 index 00000000..9cb22ff7 --- /dev/null +++ b/server/mcp-tools/rwallet.ts @@ -0,0 +1,177 @@ +/** + * MCP tools for rWallet (read-only). + * + * Tools: rwallet_get_safe_balances, rwallet_get_transfers, + * rwallet_get_defi_positions, rwallet_get_crdt_balances + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { enrichWithPrices } from "../../modules/rwallet/lib/price-feed"; +import { getDefiPositions } from "../../modules/rwallet/lib/defi-positions"; +import { getTokenDoc, listTokenDocs, getAllBalances } from "../token-service"; +import { verifyToken } from "../auth"; + +// Safe Global chain prefix map (subset for MCP) +const CHAIN_MAP: Record = { + "1": "mainnet", + "10": "optimism", + "100": "gnosis", + "137": "polygon", + "8453": "base", + "42161": "arbitrum", + "42220": "celo", + "43114": "avalanche", + "56": "bsc", + "324": "zksync", + "11155111": "sepolia", +}; + +const NATIVE_TOKENS: Record = { + "1": { name: "Ether", symbol: "ETH", decimals: 18 }, + "10": { name: "Ether", symbol: "ETH", decimals: 18 }, + "100": { name: "xDAI", symbol: "xDAI", decimals: 18 }, + "137": { name: "MATIC", symbol: "MATIC", decimals: 18 }, + "8453": { name: "Ether", symbol: "ETH", decimals: 18 }, + "42161": { name: "Ether", symbol: "ETH", decimals: 18 }, +}; + +function safeApiBase(prefix: string): string { + return `https://api.safe.global/tx-service/${prefix}/api/v1`; +} + +export function registerWalletTools(server: McpServer) { + server.tool( + "rwallet_get_safe_balances", + "Get token balances for a Safe wallet address on a specific chain, enriched with USD prices", + { + chain_id: z.string().describe("Chain ID (e.g. '1' for Ethereum, '100' for Gnosis, '8453' for Base)"), + address: z.string().describe("Wallet address (0x...)"), + }, + async ({ chain_id, address }) => { + const prefix = CHAIN_MAP[chain_id]; + if (!prefix) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain", supported: Object.keys(CHAIN_MAP) }) }] }; + } + + try { + const res = await fetch( + `${safeApiBase(prefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`, + { signal: AbortSignal.timeout(15000) }, + ); + if (!res.ok) { + return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] }; + } + + const raw = await res.json() as any[]; + const nativeToken = NATIVE_TOKENS[chain_id] || { name: "ETH", symbol: "ETH", decimals: 18 }; + const data = raw.map((item: any) => ({ + tokenAddress: item.tokenAddress, + token: item.token || nativeToken, + balance: item.balance || "0", + fiatBalance: item.fiatBalance || "0", + fiatConversion: item.fiatConversion || "0", + })); + + const enriched = (await enrichWithPrices(data, chain_id, { filterSpam: true })) + .filter(b => BigInt(b.balance || "0") > 0n); + + return { content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }] }; + } catch (e: any) { + return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] }; + } + }, + ); + + server.tool( + "rwallet_get_transfers", + "Get recent token transfers for a Safe wallet address", + { + chain_id: z.string().describe("Chain ID"), + address: z.string().describe("Wallet address (0x...)"), + limit: z.number().optional().describe("Max results (default 20)"), + }, + async ({ chain_id, address, limit }) => { + const prefix = CHAIN_MAP[chain_id]; + if (!prefix) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain" }) }] }; + } + + try { + const maxResults = Math.min(limit || 20, 100); + const res = await fetch( + `${safeApiBase(prefix)}/safes/${address}/all-transactions/?limit=${maxResults}&executed=true`, + { signal: AbortSignal.timeout(15000) }, + ); + if (!res.ok) { + return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] }; + } + + const data = await res.json() as any; + return { content: [{ type: "text", text: JSON.stringify(data.results || [], null, 2) }] }; + } catch (e: any) { + return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] }; + } + }, + ); + + server.tool( + "rwallet_get_defi_positions", + "Get DeFi protocol positions (Aave, Uniswap, etc.) for an address via Zerion", + { + address: z.string().describe("Wallet address (0x...)"), + }, + async ({ address }) => { + const positions = await getDefiPositions(address); + return { content: [{ type: "text", text: JSON.stringify(positions, null, 2) }] }; + }, + ); + + server.tool( + "rwallet_get_crdt_balances", + "Get CRDT token balances (cUSDC, $MYCO) for all holders or a specific DID (requires auth token)", + { + token: z.string().describe("JWT auth token (required — exposes DID→balance mapping)"), + did: z.string().optional().describe("Filter by DID (optional — returns all holders if omitted)"), + }, + async ({ token, did }) => { + try { + await verifyToken(token); + } catch { + return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid or expired token" }) }], isError: true }; + } + + const docIds = listTokenDocs(); + const result: Array<{ + tokenId: string; + name: string; + symbol: string; + decimals: number; + holders: Record; + }> = []; + + for (const docId of docIds) { + const tokenId = docId.replace("global:tokens:ledgers:", ""); + const doc = getTokenDoc(tokenId); + if (!doc) continue; + + let holders = getAllBalances(doc); + if (did) { + const filtered: typeof holders = {}; + if (holders[did]) filtered[did] = holders[did]; + holders = filtered; + } + + result.push({ + tokenId, + name: doc.token.name, + symbol: doc.token.symbol, + decimals: doc.token.decimals, + holders, + }); + } + + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + }, + ); +} diff --git a/server/mcp-tools/spaces.ts b/server/mcp-tools/spaces.ts new file mode 100644 index 00000000..bbc25a78 --- /dev/null +++ b/server/mcp-tools/spaces.ts @@ -0,0 +1,98 @@ +/** + * MCP tools for rSpace space discovery. + * + * Tools: list_spaces, list_modules + * Resource: rspace://spaces/{slug} + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { listCommunities, loadCommunity, getDocumentData, normalizeVisibility } from "../community-store"; +import { getModuleInfoList } from "../../shared/module"; +import { resolveAccess } from "./_auth"; + +export function registerSpacesTools(server: McpServer) { + server.tool( + "list_spaces", + "List all rSpace spaces with metadata (name, visibility, owner, members, enabled modules)", + { + token: z.string().optional().describe("JWT auth token (shows private/permissioned spaces you have access to)"), + }, + async ({ token }) => { + const slugs = await listCommunities(); + const spaces = []; + for (const slug of slugs) { + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) continue; + + const visibility = normalizeVisibility(data.meta.visibility || "private"); + + // Filter out spaces the caller can't see + if (visibility !== "public") { + const access = await resolveAccess(token, slug, false); + if (!access.allowed) continue; + } + + spaces.push({ + slug, + name: data.meta.name, + visibility, + ownerDID: data.meta.ownerDID, + description: data.meta.description || null, + enabledModules: data.meta.enabledModules || null, + memberCount: Object.keys(data.members || {}).length, + shapeCount: Object.keys(data.shapes || {}).length, + createdAt: data.meta.createdAt, + }); + } + return { + content: [{ type: "text", text: JSON.stringify(spaces, null, 2) }], + }; + }, + ); + + server.tool( + "list_modules", + "List all available rSpace modules (rApps) with metadata", + {}, + async () => { + const modules = getModuleInfoList(); + return { + content: [{ type: "text", text: JSON.stringify(modules, null, 2) }], + }; + }, + ); + + server.resource( + "space_metadata", + "rspace://spaces/{slug}", + { description: "Get detailed metadata for a specific rSpace space" }, + async (uri) => { + const slug = uri.pathname.split("/").pop(); + if (!slug) { + return { contents: [{ uri: uri.href, mimeType: "application/json", text: '{"error":"Missing slug"}' }] }; + } + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) { + return { contents: [{ uri: uri.href, mimeType: "application/json", text: '{"error":"Space not found"}' }] }; + } + const result = { + slug, + name: data.meta.name, + visibility: data.meta.visibility, + ownerDID: data.meta.ownerDID, + description: data.meta.description || null, + enabledModules: data.meta.enabledModules || null, + members: Object.values(data.members || {}), + shapeCount: Object.keys(data.shapes || {}).length, + nestedSpaceCount: Object.keys(data.nestedSpaces || {}).length, + createdAt: data.meta.createdAt, + }; + return { + contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(result, null, 2) }], + }; + }, + ); +} diff --git a/server/mi-data-queries.ts b/server/mi-data-queries.ts index fc73972e..4065739a 100644 --- a/server/mi-data-queries.ts +++ b/server/mi-data-queries.ts @@ -8,6 +8,38 @@ import { getUpcomingEventsForMI } from "../modules/rcal/mod"; import { getRecentNotesForMI } from "../modules/rnotes/mod"; import { getRecentTasksForMI } from "../modules/rtasks/mod"; +import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; +import { getRecentContactsForMI } from "../modules/rnetwork/mod"; +import { getRecentThreadsForMI } from "../modules/rinbox/mod"; +import { getRecentCommitmentsForMI } from "../modules/rtime/mod"; +import { getRecentFilesForMI } from "../modules/rfiles/mod"; +import { getUpcomingRemindersForMI } from "../modules/rschedule/mod"; +import { getMapPinsForMI } from "../modules/rmaps/mod"; +import { getRecentMeetingsForMI } from "../modules/rmeets/mod"; +import { getRecentVideosForMI } from "../modules/rtube/mod"; +import { getRecentMessagesForMI } from "../modules/rchats/mod"; +import { getRecentPublicationsForMI } from "../modules/rpubs/mod"; +import { getRecentDesignsForMI } from "../modules/rswag/mod"; +import { getRecentSheetsForMI } from "../modules/rsheet/mod"; +import { getLinkedDocsForMI } from "../modules/rdocs/mod"; +import { getRecentSessionsForMI } from "../modules/rdesign/mod"; +import { getSharedAlbumsForMI } from "../modules/rphotos/mod"; +import { getRecentFlowsForMI } from "../modules/rflows/mod"; +import { getRecentIntentsForMI } from "../modules/rexchange/mod"; +import { getRecentOrdersForMI } from "../modules/rcart/mod"; +import { getActiveProposalsForMI } from "../modules/rvote/mod"; +import { getRecentBooksForMI } from "../modules/rbooks/mod"; +import { getRecentSplatsForMI } from "../modules/rsplat/mod"; +import { getRecentTripsForMI } from "../modules/rtrips/mod"; +import { getActiveListingsForMI } from "../modules/rbnb/mod"; +import { getActiveVehiclesForMI } from "../modules/rvnb/mod"; +import { getForumInstancesForMI } from "../modules/rforum/mod"; +import { getRecentChoiceSessionsForMI } from "../modules/rchoices/mod"; +import { getActivePromptsForMI } from "../modules/crowdsurf/mod"; +import { getGovShapesForMI } from "../modules/rgov/mod"; +import { getCrdtTokensForMI } from "../modules/rwallet/mod"; +import { getCanvasSummaryForMI } from "../modules/rspace/mod"; +import { getDataSummaryForMI } from "../modules/rdata/mod"; export interface MiQueryResult { ok: boolean; @@ -60,6 +92,297 @@ export function queryModuleContent( return { ok: true, module, queryType, data: events, summary: lines.length ? `Upcoming events:\n${lines.join("\n")}` : "No upcoming events." }; } + case "rsocials": { + const campaigns = getRecentCampaignsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: campaigns.length }, summary: `${campaigns.length} campaigns found.` }; + } + const lines = campaigns.map((c) => `- "${c.title}" (${c.platforms.join(", ")}, ${c.postCount} posts)`); + return { ok: true, module, queryType, data: campaigns, summary: lines.length ? `Recent campaigns:\n${lines.join("\n")}` : "No campaigns found." }; + } + + case "rnetwork": { + const contacts = getRecentContactsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: contacts.length }, summary: `${contacts.length} contacts found.` }; + } + const lines = contacts.map((c) => `- ${c.name} (${c.role})${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`); + return { ok: true, module, queryType, data: contacts, summary: lines.length ? `Contacts:\n${lines.join("\n")}` : "No contacts found." }; + } + + case "rinbox": { + const threads = getRecentThreadsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: threads.length }, summary: `${threads.length} email threads found.` }; + } + const lines = threads.map((t) => `- "${t.subject}" from ${t.fromAddress || "unknown"} [${t.status}]${t.isRead ? "" : " (unread)"}`); + return { ok: true, module, queryType, data: threads, summary: lines.length ? `Recent threads:\n${lines.join("\n")}` : "No email threads." }; + } + + case "rtime": { + const commitments = getRecentCommitmentsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: commitments.length }, summary: `${commitments.length} active commitments.` }; + } + const lines = commitments.map((c) => `- ${c.memberName}: ${c.hours}h ${c.skill} — ${c.desc.slice(0, 80)}`); + return { ok: true, module, queryType, data: commitments, summary: lines.length ? `Active commitments:\n${lines.join("\n")}` : "No active commitments." }; + } + + case "rfiles": { + const files = getRecentFilesForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: files.length }, summary: `${files.length} files found.` }; + } + const lines = files.map((f) => `- ${f.title || f.originalFilename} (${f.mimeType || "unknown"}, ${Math.round(f.fileSize / 1024)}KB)`); + return { ok: true, module, queryType, data: files, summary: lines.length ? `Recent files:\n${lines.join("\n")}` : "No files found." }; + } + + case "rschedule": { + const reminders = getUpcomingRemindersForMI(space, 14, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: reminders.length }, summary: `${reminders.length} upcoming reminders.` }; + } + const lines = reminders.map((r) => { + const date = new Date(r.remindAt).toISOString().split("T")[0]; + let line = `- ${date}: ${r.title}`; + if (r.sourceModule) line += ` (from ${r.sourceModule})`; + return line; + }); + return { ok: true, module, queryType, data: reminders, summary: lines.length ? `Upcoming reminders:\n${lines.join("\n")}` : "No upcoming reminders." }; + } + + case "rmaps": { + const pins = getMapPinsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: pins.length }, summary: `${pins.length} map pins found.` }; + } + const lines = pins.map((p) => `- "${p.label}" (${p.type}) at ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`); + return { ok: true, module, queryType, data: pins, summary: lines.length ? `Map pins:\n${lines.join("\n")}` : "No map pins." }; + } + + case "rmeets": { + const meetings = getRecentMeetingsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: meetings.length }, summary: `${meetings.length} meetings found.` }; + } + const lines = meetings.map((m) => `- "${m.title}" (${m.participantCount} participants, ${new Date(m.scheduledAt).toLocaleDateString()})`); + return { ok: true, module, queryType, data: meetings, summary: lines.length ? `Recent meetings:\n${lines.join("\n")}` : "No meetings found." }; + } + + case "rtube": { + const videos = getRecentVideosForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: videos.length }, summary: `${videos.length} playlists found.` }; + } + const lines = videos.map((v) => `- "${v.name}" (${v.entryCount} entries)`); + return { ok: true, module, queryType, data: videos, summary: lines.length ? `Playlists:\n${lines.join("\n")}` : "No playlists found." }; + } + + case "rchats": { + const msgs = getRecentMessagesForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: msgs.length }, summary: `${msgs.length} recent messages.` }; + } + const lines = msgs.map((m) => `- [${m.channel}] ${m.author}: ${m.content.slice(0, 100)}`); + return { ok: true, module, queryType, data: msgs, summary: lines.length ? `Recent chats:\n${lines.join("\n")}` : "No chat messages." }; + } + + case "rpubs": { + const pubs = getRecentPublicationsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: pubs.length }, summary: `${pubs.length} publications found.` }; + } + const lines = pubs.map((p) => `- "${p.title}" by ${p.author} (${p.format})`); + return { ok: true, module, queryType, data: pubs, summary: lines.length ? `Publications:\n${lines.join("\n")}` : "No publications found." }; + } + + case "rswag": { + const designs = getRecentDesignsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: designs.length }, summary: `${designs.length} designs found.` }; + } + const lines = designs.map((d) => `- "${d.title}" (${d.productType}, ${d.status})`); + return { ok: true, module, queryType, data: designs, summary: lines.length ? `Store designs:\n${lines.join("\n")}` : "No designs found." }; + } + + case "rsheet": { + const sheets = getRecentSheetsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: sheets.length }, summary: `${sheets.length} spreadsheets found.` }; + } + const lines = sheets.map((s) => `- "${s.name}" (${s.cellCount} cells, updated ${new Date(s.updatedAt).toLocaleDateString()})`); + return { ok: true, module, queryType, data: sheets, summary: lines.length ? `Spreadsheets:\n${lines.join("\n")}` : "No spreadsheets found." }; + } + + case "rdocs": { + const docs = getLinkedDocsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: docs.length }, summary: `${docs.length} documents found.` }; + } + const lines = docs.map((d) => `- "${d.title}" (added ${new Date(d.addedAt).toLocaleDateString()})`); + return { ok: true, module, queryType, data: docs, summary: lines.length ? `Documents:\n${lines.join("\n")}` : "No documents found." }; + } + + case "rdesign": { + const sessions = getRecentSessionsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: sessions.length }, summary: `${sessions.length} design sessions found.` }; + } + const lines = sessions.map((s) => `- "${s.title}" (${s.pageCount} pages, ${s.frameCount} frames)`); + return { ok: true, module, queryType, data: sessions, summary: lines.length ? `Design sessions:\n${lines.join("\n")}` : "No design sessions." }; + } + + case "rphotos": { + const albums = getSharedAlbumsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: albums.length }, summary: `${albums.length} shared albums found.` }; + } + const lines = albums.map((a) => `- "${a.name}" (shared ${new Date(a.sharedAt).toLocaleDateString()})`); + return { ok: true, module, queryType, data: albums, summary: lines.length ? `Shared albums:\n${lines.join("\n")}` : "No shared albums." }; + } + + case "rflows": { + const flows = getRecentFlowsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: flows.length }, summary: `${flows.length} flows found.` }; + } + const lines = flows.map((f) => `- "${f.name}" (${f.nodeCount} nodes)`); + return { ok: true, module, queryType, data: flows, summary: lines.length ? `Flows:\n${lines.join("\n")}` : "No flows found." }; + } + + case "rexchange": { + const intents = getRecentIntentsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: intents.length }, summary: `${intents.length} exchange intents.` }; + } + const lines = intents.map((i) => `- ${i.side} ${i.tokenId} [${i.status}]`); + return { ok: true, module, queryType, data: intents, summary: lines.length ? `Exchange intents:\n${lines.join("\n")}` : "No exchange intents." }; + } + + case "rcart": { + const orders = getRecentOrdersForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: orders.length }, summary: `${orders.length} orders found.` }; + } + const lines = orders.map((o) => `- "${o.title}" [${o.status}] ($${o.totalPrice})`); + return { ok: true, module, queryType, data: orders, summary: lines.length ? `Recent orders:\n${lines.join("\n")}` : "No orders found." }; + } + + case "rvote": { + const proposals = getActiveProposalsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: proposals.length }, summary: `${proposals.length} proposals found.` }; + } + const lines = proposals.map((p) => `- "${p.title}" [${p.status}] (score: ${p.score}, ${p.voteCount} votes)`); + return { ok: true, module, queryType, data: proposals, summary: lines.length ? `Proposals:\n${lines.join("\n")}` : "No proposals found." }; + } + + case "rbooks": { + const books = getRecentBooksForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: books.length }, summary: `${books.length} books found.` }; + } + const lines = books.map((b) => `- "${b.title}" by ${b.author} (${b.pageCount} pages)`); + return { ok: true, module, queryType, data: books, summary: lines.length ? `Books:\n${lines.join("\n")}` : "No books found." }; + } + + case "rsplat": { + const splats = getRecentSplatsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: splats.length }, summary: `${splats.length} 3D scenes found.` }; + } + const lines = splats.map((s) => `- "${s.title}" (${s.format}, ${s.status})`); + return { ok: true, module, queryType, data: splats, summary: lines.length ? `3D scenes:\n${lines.join("\n")}` : "No 3D scenes found." }; + } + + case "rtrips": { + const trips = getRecentTripsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: trips.length }, summary: `${trips.length} trips found.` }; + } + const lines = trips.map((t) => `- "${t.title}" [${t.status}] (${t.destinationCount} destinations${t.startDate ? `, starts ${t.startDate}` : ""})`); + return { ok: true, module, queryType, data: trips, summary: lines.length ? `Trips:\n${lines.join("\n")}` : "No trips found." }; + } + + case "rbnb": { + const listings = getActiveListingsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: listings.length }, summary: `${listings.length} active listings.` }; + } + const lines = listings.map((l) => `- "${l.title}" (${l.type}, ${l.locationName || "unknown location"}, ${l.economy})`); + return { ok: true, module, queryType, data: listings, summary: lines.length ? `Listings:\n${lines.join("\n")}` : "No active listings." }; + } + + case "rvnb": { + const vehicles = getActiveVehiclesForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: vehicles.length }, summary: `${vehicles.length} active vehicles.` }; + } + const lines = vehicles.map((v) => `- "${v.title}" (${v.type}, ${v.locationName || "unknown location"}, ${v.economy})`); + return { ok: true, module, queryType, data: vehicles, summary: lines.length ? `Vehicles:\n${lines.join("\n")}` : "No active vehicles." }; + } + + case "rforum": { + const instances = getForumInstancesForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: instances.length }, summary: `${instances.length} forum instances.` }; + } + const lines = instances.map((i) => `- "${i.name}" (${i.domain || "no domain"}) [${i.status}]`); + return { ok: true, module, queryType, data: instances, summary: lines.length ? `Forum instances:\n${lines.join("\n")}` : "No forum instances." }; + } + + case "rchoices": { + const sessions = getRecentChoiceSessionsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: sessions.length }, summary: `${sessions.length} choice sessions.` }; + } + const lines = sessions.map((s) => `- "${s.title}" (${s.type}, ${s.optionCount} options${s.closed ? ", closed" : ""})`); + return { ok: true, module, queryType, data: sessions, summary: lines.length ? `Choice sessions:\n${lines.join("\n")}` : "No choice sessions." }; + } + + case "crowdsurf": { + const prompts = getActivePromptsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: prompts.length }, summary: `${prompts.length} crowdsurf prompts.` }; + } + const lines = prompts.map((p) => `- "${p.text.slice(0, 80)}" (${p.swipeCount}/${p.threshold} swipes${p.triggered ? ", triggered" : ""})`); + return { ok: true, module, queryType, data: prompts, summary: lines.length ? `Crowdsurf prompts:\n${lines.join("\n")}` : "No crowdsurf prompts." }; + } + + case "rgov": { + const shapes = getGovShapesForMI(space, limit); + if (queryType === "count") { + const total = shapes.reduce((sum, s) => sum + s.count, 0); + return { ok: true, module, queryType, data: { count: total }, summary: `${total} governance shapes.` }; + } + const lines = shapes.map((s) => `- ${s.type}: ${s.count}`); + return { ok: true, module, queryType, data: shapes, summary: lines.length ? `Governance shapes:\n${lines.join("\n")}` : "No governance shapes." }; + } + + case "rwallet": { + const tokens = getCrdtTokensForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: tokens.length }, summary: `${tokens.length} CRDT tokens.` }; + } + const lines = tokens.map((t) => `- ${t.symbol} (${t.name}): supply ${t.totalSupply}`); + return { ok: true, module, queryType, data: tokens, summary: lines.length ? `CRDT tokens:\n${lines.join("\n")}` : "No CRDT tokens." }; + } + + case "rspace": { + const summary = getCanvasSummaryForMI(space, limit); + if (!summary.length || !summary[0].totalShapes) { + return { ok: true, module, queryType, data: summary, summary: "Empty canvas." }; + } + const s = summary[0]; + const lines = s.typeBreakdown.map((t) => `- ${t.type}: ${t.count}`); + return { ok: true, module, queryType, data: summary, summary: `Canvas: ${s.totalShapes} shapes\n${lines.join("\n")}` }; + } + + case "rdata": { + const data = getDataSummaryForMI(space, limit); + return { ok: true, module, queryType, data, summary: "rData proxies Umami analytics — use the dashboard for stats." }; + } + default: return { ok: false, module, queryType, data: null, summary: `Module "${module}" does not support content queries.` }; } diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 31ac5f33..d90fd70c 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -21,6 +21,38 @@ import type { MiAction } from "../lib/mi-actions"; import { getUpcomingEventsForMI } from "../modules/rcal/mod"; import { getRecentNotesForMI } from "../modules/rnotes/mod"; import { getRecentTasksForMI } from "../modules/rtasks/mod"; +import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; +import { getRecentContactsForMI } from "../modules/rnetwork/mod"; +import { getRecentThreadsForMI } from "../modules/rinbox/mod"; +import { getRecentCommitmentsForMI } from "../modules/rtime/mod"; +import { getRecentFilesForMI } from "../modules/rfiles/mod"; +import { getUpcomingRemindersForMI } from "../modules/rschedule/mod"; +import { getMapPinsForMI } from "../modules/rmaps/mod"; +import { getRecentMeetingsForMI } from "../modules/rmeets/mod"; +import { getRecentVideosForMI } from "../modules/rtube/mod"; +import { getRecentMessagesForMI } from "../modules/rchats/mod"; +import { getRecentPublicationsForMI } from "../modules/rpubs/mod"; +import { getRecentDesignsForMI } from "../modules/rswag/mod"; +import { getRecentSheetsForMI } from "../modules/rsheet/mod"; +import { getLinkedDocsForMI } from "../modules/rdocs/mod"; +import { getRecentSessionsForMI } from "../modules/rdesign/mod"; +import { getSharedAlbumsForMI } from "../modules/rphotos/mod"; +import { getRecentFlowsForMI } from "../modules/rflows/mod"; +import { getRecentIntentsForMI } from "../modules/rexchange/mod"; +import { getRecentOrdersForMI } from "../modules/rcart/mod"; +import { getActiveProposalsForMI } from "../modules/rvote/mod"; +import { getRecentBooksForMI } from "../modules/rbooks/mod"; +import { getRecentSplatsForMI } from "../modules/rsplat/mod"; +import { getRecentTripsForMI } from "../modules/rtrips/mod"; +import { getActiveListingsForMI } from "../modules/rbnb/mod"; +import { getActiveVehiclesForMI } from "../modules/rvnb/mod"; +import { getForumInstancesForMI } from "../modules/rforum/mod"; +import { getRecentChoiceSessionsForMI } from "../modules/rchoices/mod"; +import { getActivePromptsForMI } from "../modules/crowdsurf/mod"; +import { getGovShapesForMI } from "../modules/rgov/mod"; +import { getCrdtTokensForMI } from "../modules/rwallet/mod"; +import { getCanvasSummaryForMI } from "../modules/rspace/mod"; +import { getDataSummaryForMI } from "../modules/rdata/mod"; import { runAgenticLoop } from "./mi-agent"; import { generateImage, generateVideoViaFal } from "./mi-media"; import { queryModuleContent } from "./mi-data-queries"; @@ -157,6 +189,37 @@ mi.post("/ask", async (c) => { let calendarContext = ""; let notesContext = ""; let tasksContext = ""; + let campaignsContext = ""; + let contactsContext = ""; + let inboxContext = ""; + let commitmentsContext = ""; + let filesContext = ""; + let remindersContext = ""; + let mapsContext = ""; + let meetsContext = ""; + let tubeContext = ""; + let chatsContext = ""; + let pubsContext = ""; + let swagContext = ""; + let sheetsContext = ""; + let docsContext = ""; + let designContext = ""; + let photosContext = ""; + let flowsContext = ""; + let exchangeContext = ""; + let cartContext = ""; + let voteContext = ""; + let booksContext = ""; + let splatsContext = ""; + let tripsContext = ""; + let bnbContext = ""; + let vnbContext = ""; + let forumContext = ""; + let choicesContext = ""; + let crowdsurfContext = ""; + let govContext = ""; + let walletContext = ""; + let canvasContext = ""; if (space) { const upcoming = getUpcomingEventsForMI(space); if (upcoming.length > 0) { @@ -186,6 +249,206 @@ mi.post("/ask", async (c) => { ); tasksContext = `\n- Open tasks:\n${lines.join("\n")}`; } + + const recentCampaigns = getRecentCampaignsForMI(space, 3); + if (recentCampaigns.length > 0) { + const lines = recentCampaigns.map((c) => + `- "${c.title}" (${c.platforms.join(", ")}, ${c.postCount} posts)` + ); + campaignsContext = `\n- Recent campaigns:\n${lines.join("\n")}`; + } + + const contacts = getRecentContactsForMI(space, 3); + if (contacts.length > 0) { + const lines = contacts.map((c) => + `- ${c.name} (${c.role})${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}` + ); + contactsContext = `\n- Network contacts:\n${lines.join("\n")}`; + } + + const recentThreads = getRecentThreadsForMI(space, 3); + if (recentThreads.length > 0) { + const lines = recentThreads.map((t) => + `- "${t.subject}" from ${t.fromAddress || "unknown"} [${t.status}]${t.isRead ? "" : " (unread)"}` + ); + inboxContext = `\n- Recent emails:\n${lines.join("\n")}`; + } + + const commitments = getRecentCommitmentsForMI(space, 3); + if (commitments.length > 0) { + const lines = commitments.map((c) => + `- ${c.memberName}: ${c.hours}h ${c.skill} — ${c.desc.slice(0, 80)}` + ); + commitmentsContext = `\n- Active commitments:\n${lines.join("\n")}`; + } + + const recentFiles = getRecentFilesForMI(space, 3); + if (recentFiles.length > 0) { + const lines = recentFiles.map((f) => + `- ${f.title || f.originalFilename} (${f.mimeType || "unknown"})` + ); + filesContext = `\n- Recent files:\n${lines.join("\n")}`; + } + + const reminders = getUpcomingRemindersForMI(space, 14, 3); + if (reminders.length > 0) { + const lines = reminders.map((r) => { + const date = new Date(r.remindAt).toISOString().split("T")[0]; + return `- ${date}: ${r.title}${r.sourceModule ? ` (from ${r.sourceModule})` : ""}`; + }); + remindersContext = `\n- Upcoming reminders:\n${lines.join("\n")}`; + } + + const pins = getMapPinsForMI(space, 5); + if (pins.length > 0) { + const lines = pins.map((p) => `- "${p.label}" (${p.type}) at ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`); + mapsContext = `\n- Map pins:\n${lines.join("\n")}`; + } + + const meetings = getRecentMeetingsForMI(space, 3); + if (meetings.length > 0) { + const lines = meetings.map((m) => `- "${m.title}" (${m.participantCount} participants)`); + meetsContext = `\n- Recent meetings:\n${lines.join("\n")}`; + } + + const playlists = getRecentVideosForMI(space, 3); + if (playlists.length > 0) { + const lines = playlists.map((v) => `- "${v.name}" (${v.entryCount} entries)`); + tubeContext = `\n- Playlists:\n${lines.join("\n")}`; + } + + const chatMsgs = getRecentMessagesForMI(space, 3); + if (chatMsgs.length > 0) { + const lines = chatMsgs.map((m) => `- [${m.channel}] ${m.author}: ${m.content.slice(0, 80)}`); + chatsContext = `\n- Recent chats:\n${lines.join("\n")}`; + } + + const pubs = getRecentPublicationsForMI(space, 3); + if (pubs.length > 0) { + const lines = pubs.map((p) => `- "${p.title}" by ${p.author} (${p.format})`); + pubsContext = `\n- Publications:\n${lines.join("\n")}`; + } + + const swagDesigns = getRecentDesignsForMI(space, 3); + if (swagDesigns.length > 0) { + const lines = swagDesigns.map((d) => `- "${d.title}" (${d.productType}, ${d.status})`); + swagContext = `\n- Store designs:\n${lines.join("\n")}`; + } + + const sheets = getRecentSheetsForMI(space, 3); + if (sheets.length > 0) { + const lines = sheets.map((s) => `- "${s.name}" (${s.cellCount} cells)`); + sheetsContext = `\n- Spreadsheets:\n${lines.join("\n")}`; + } + + const linkedDocs = getLinkedDocsForMI(space, 3); + if (linkedDocs.length > 0) { + const lines = linkedDocs.map((d) => `- "${d.title}"`); + docsContext = `\n- Documents:\n${lines.join("\n")}`; + } + + const designSessions = getRecentSessionsForMI(space, 3); + if (designSessions.length > 0) { + const lines = designSessions.map((s) => `- "${s.title}" (${s.pageCount} pages, ${s.frameCount} frames)`); + designContext = `\n- Design sessions:\n${lines.join("\n")}`; + } + + const albums = getSharedAlbumsForMI(space, 3); + if (albums.length > 0) { + const lines = albums.map((a) => `- "${a.name}"`); + photosContext = `\n- Shared albums:\n${lines.join("\n")}`; + } + + const flows = getRecentFlowsForMI(space, 3); + if (flows.length > 0) { + const lines = flows.map((f) => `- "${f.name}" (${f.nodeCount} nodes)`); + flowsContext = `\n- Flows:\n${lines.join("\n")}`; + } + + const intents = getRecentIntentsForMI(space, 3); + if (intents.length > 0) { + const lines = intents.map((i) => `- ${i.side} ${i.tokenId} [${i.status}]`); + exchangeContext = `\n- Exchange intents:\n${lines.join("\n")}`; + } + + const orders = getRecentOrdersForMI(space, 3); + if (orders.length > 0) { + const lines = orders.map((o) => `- "${o.title}" [${o.status}]`); + cartContext = `\n- Recent orders:\n${lines.join("\n")}`; + } + + const proposals = getActiveProposalsForMI(space, 3); + if (proposals.length > 0) { + const lines = proposals.map((p) => `- "${p.title}" [${p.status}] (${p.voteCount} votes)`); + voteContext = `\n- Proposals:\n${lines.join("\n")}`; + } + + const books = getRecentBooksForMI(space, 3); + if (books.length > 0) { + const lines = books.map((b) => `- "${b.title}" by ${b.author}`); + booksContext = `\n- Books:\n${lines.join("\n")}`; + } + + const splats = getRecentSplatsForMI(space, 3); + if (splats.length > 0) { + const lines = splats.map((s) => `- "${s.title}" (${s.format})`); + splatsContext = `\n- 3D scenes:\n${lines.join("\n")}`; + } + + const trips = getRecentTripsForMI(space, 3); + if (trips.length > 0) { + const lines = trips.map((t) => `- "${t.title}" [${t.status}] (${t.destinationCount} destinations)`); + tripsContext = `\n- Trips:\n${lines.join("\n")}`; + } + + const bnbListings = getActiveListingsForMI(space, 3); + if (bnbListings.length > 0) { + const lines = bnbListings.map((l) => `- "${l.title}" (${l.type}, ${l.economy})`); + bnbContext = `\n- BnB listings:\n${lines.join("\n")}`; + } + + const vnbVehicles = getActiveVehiclesForMI(space, 3); + if (vnbVehicles.length > 0) { + const lines = vnbVehicles.map((v) => `- "${v.title}" (${v.type}, ${v.economy})`); + vnbContext = `\n- Vehicles:\n${lines.join("\n")}`; + } + + const forumInstances = getForumInstancesForMI(space, 3); + if (forumInstances.length > 0) { + const lines = forumInstances.map((i) => `- "${i.name}" (${i.domain || "pending"}) [${i.status}]`); + forumContext = `\n- Forum instances:\n${lines.join("\n")}`; + } + + const choiceSessions = getRecentChoiceSessionsForMI(space, 3); + if (choiceSessions.length > 0) { + const lines = choiceSessions.map((s) => `- "${s.title}" (${s.type}, ${s.optionCount} options)`); + choicesContext = `\n- Choice sessions:\n${lines.join("\n")}`; + } + + const csPrompts = getActivePromptsForMI(space, 3); + if (csPrompts.length > 0) { + const lines = csPrompts.map((p) => `- "${p.text.slice(0, 60)}" (${p.swipeCount}/${p.threshold})`); + crowdsurfContext = `\n- Crowdsurf prompts:\n${lines.join("\n")}`; + } + + const govShapes = getGovShapesForMI(space); + if (govShapes.length > 0) { + const lines = govShapes.map((s) => `- ${s.type}: ${s.count}`); + govContext = `\n- Governance shapes:\n${lines.join("\n")}`; + } + + const crdtTokens = getCrdtTokensForMI(space, 3); + if (crdtTokens.length > 0) { + const lines = crdtTokens.map((t) => `- ${t.symbol} (${t.name}): supply ${t.totalSupply}`); + walletContext = `\n- CRDT tokens:\n${lines.join("\n")}`; + } + + const canvasSummary = getCanvasSummaryForMI(space); + if (canvasSummary.length > 0 && canvasSummary[0].totalShapes > 0) { + const s = canvasSummary[0]; + const top = s.typeBreakdown.slice(0, 5).map((t) => `${t.type}: ${t.count}`).join(", "); + canvasContext = `\n- Canvas: ${s.totalShapes} shapes (${top})`; + } } const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. @@ -211,7 +474,7 @@ ${moduleCapabilities} When the user asks to create a social media campaign, use create-content with module rsocials, contentType campaign, body.rawBrief set to the user's description, body.navigateToWizard true. ## Current Context -${contextSection}${calendarContext}${notesContext}${tasksContext} +${contextSection}${calendarContext}${notesContext}${tasksContext}${campaignsContext}${contactsContext}${inboxContext}${commitmentsContext}${filesContext}${remindersContext}${mapsContext}${meetsContext}${tubeContext}${chatsContext}${pubsContext}${swagContext}${sheetsContext}${docsContext}${designContext}${photosContext}${flowsContext}${exchangeContext}${cartContext}${voteContext}${booksContext}${splatsContext}${tripsContext}${bnbContext}${vnbContext}${forumContext}${choicesContext}${crowdsurfContext}${govContext}${walletContext}${canvasContext} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. @@ -276,6 +539,38 @@ When you need to look up the user's actual data (notes, tasks, events): [MI_ACTION:{"type":"query-content","module":"rnotes","queryType":"recent","limit":5}] [MI_ACTION:{"type":"query-content","module":"rtasks","queryType":"recent","limit":5}] [MI_ACTION:{"type":"query-content","module":"rcal","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rsocials","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rnetwork","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rinbox","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rtime","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rfiles","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rschedule","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rmaps","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rmeets","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rtube","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rchats","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rpubs","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rswag","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rsheet","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rdocs","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rdesign","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rphotos","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rflows","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rexchange","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rcart","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rvote","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rbooks","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rsplat","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rtrips","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rbnb","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rvnb","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rforum","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rchoices","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"crowdsurf","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rgov","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rwallet","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rspace","queryType":"recent","limit":5}] +[MI_ACTION:{"type":"query-content","module":"rdata","queryType":"recent","limit":5}] queryType can be: "recent", "summary", or "count". Results will be provided in a follow-up message for you to incorporate into your response.