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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 10:25:17 -04:00
parent 234c8e6703
commit 2e8e702d75
72 changed files with 6219 additions and 1 deletions

View File

@ -191,6 +191,28 @@ function seedTemplateCrowdSurf(space: string) {
console.log(`[CrowdSurf] Template seeded for "${space}": 3 prompt shapes`); 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<string, any>)) {
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 = { export const crowdsurfModule: RSpaceModule = {
id: "crowdsurf", id: "crowdsurf",
name: "CrowdSurf", name: "CrowdSurf",

View File

@ -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<BnbDoc>(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 ── // ── Module export ──
export const bnbModule: RSpaceModule = { export const bnbModule: RSpaceModule = {

View File

@ -422,6 +422,17 @@ function seedTemplateBooks(space: string) {
// ── Module export ── // ── 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<BooksCatalogDoc>(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 = { export const booksModule: RSpaceModule = {
id: "rbooks", id: "rbooks",
name: "rBooks", name: "rBooks",

View File

@ -2672,6 +2672,19 @@ function seedTemplateCart(space: string) {
console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`); 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<OrderDoc>(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 = { export const cartModule: RSpaceModule = {
id: "rcart", id: "rcart",
name: "rCart", name: "rCart",

View File

@ -6,13 +6,124 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; 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(); const routes = new Hono();
// ── Local-first helpers ──
function ensureDirectoryDoc(space: string): ChatsDirectoryDoc {
const docId = chatsDirectoryDocId(space);
let doc = _syncServer!.getDoc<ChatsDirectoryDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatsDirectoryDoc>(), '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<ChatChannelDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ChatChannelDoc>(), '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<ChatsDirectoryDoc>(docId, `create channel ${id}`, (d) => {
d.channels[id] = { id, name, description, isPrivate, createdBy: null, createdAt: Date.now(), updatedAt: Date.now() };
});
const updated = _syncServer.getDoc<ChatsDirectoryDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(docId, `delete message ${msgId}`, (d) => { delete d.messages[msgId]; });
return c.json({ ok: true });
});
// ── Hub page (Coming Soon dashboard) ── // ── Hub page (Coming Soon dashboard) ──
routes.get("/", (c) => { 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<ChatChannelDoc>(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 ── // ── Module export ──
export const chatsModule: RSpaceModule = { export const chatsModule: RSpaceModule = {
@ -76,6 +203,11 @@ export const chatsModule: RSpaceModule = {
icon: "🗨️", icon: "🗨️",
description: "Encrypted community messaging", description: "Encrypted community messaging",
scoping: { defaultScope: "space", userConfigurable: false }, 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, routes,
landingPage: renderLanding, landingPage: renderLanding,
async onInit(ctx) { _syncServer = ctx.syncServer; },
}; };

View File

@ -171,6 +171,17 @@ function seedTemplateChoices(space: string) {
console.log(`[Choices] Template seeded for "${space}": 3 choice shapes`); 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<ChoicesDoc>(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 = { export const choicesModule: RSpaceModule = {
id: "rchoices", id: "rchoices",
name: "rChoices", name: "rChoices",

View File

@ -287,6 +287,12 @@ routes.get("/:tabId", (c, next) => {
return c.html(renderDataPage(space, tabId, c.get("isSubdomain"))); 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 = { export const dataModule: RSpaceModule = {
id: "rdata", id: "rdata",
name: "rData", name: "rData",

View File

@ -11,6 +11,27 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { designAgentRoutes } from "./design-agent-route"; import { designAgentRoutes } from "./design-agent-route";
import { ensureSidecar } from "../../server/sidecar-manager"; 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<DesignDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<DesignDoc>(), '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(); 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) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const view = c.req.query("view"); 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<DesignDoc>(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 = { export const designModule: RSpaceModule = {
id: "rdesign", id: "rdesign",
name: "rDesign", name: "rDesign",
@ -680,6 +722,8 @@ export const designModule: RSpaceModule = {
scoping: { defaultScope: 'global', userConfigurable: false }, scoping: { defaultScope: 'global', userConfigurable: false },
publicWrite: true, publicWrite: true,
routes, routes,
docSchemas: [{ pattern: '{space}:design:doc', description: 'Design document per space', init: designSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
landingPage: renderDesignLanding, landingPage: renderDesignLanding,
feeds: [ feeds: [
{ id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, layouts, and print-ready exports" }, { id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, layouts, and print-ready exports" },

View File

@ -5,14 +5,79 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell, renderExternalAppShell } from "../../server/shell"; import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } 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 routes = new Hono();
const DOCMOST_URL = "https://docs.cosmolocal.world"; const DOCMOST_URL = "https://docs.cosmolocal.world";
// ── Local-first helpers ──
function ensureDocsDoc(space: string): DocsDoc {
const docId = docsDocId(space);
let doc = _syncServer!.getDoc<DocsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<DocsDoc>(), '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<DocsDoc>(docId, `register document ${id}`, (d) => {
d.linkedDocuments[id] = { id, url, title, addedBy: claims.sub || null, addedAt: Date.now() };
});
const updated = _syncServer.getDoc<DocsDoc>(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<DocsDoc>(docId, `unregister document ${docRefId}`, (d) => { delete d.linkedDocuments[docRefId]; });
return c.json({ ok: true });
});
routes.get("/api/health", (c) => { routes.get("/api/health", (c) => {
return c.json({ ok: true, module: "rdocs" }); return c.json({ ok: true, module: "rdocs" });
}); });
@ -58,15 +123,30 @@ function renderDocsLanding(): string {
</div>`; </div>`;
} }
// ── 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<DocsDoc>(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 = { export const docsModule: RSpaceModule = {
id: "rdocs", id: "rdocs",
name: "rDocs", name: "rDocs",
icon: "📝", icon: "📝",
description: "Collaborative documentation and knowledge base", description: "Collaborative documentation and knowledge base",
scoping: { defaultScope: 'global', userConfigurable: true }, scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: '{space}:docs:links', description: 'Linked Docmost documents per space', init: docsSchema.init }],
routes, routes,
landingPage: renderDocsLanding, landingPage: renderDocsLanding,
externalApp: { url: DOCMOST_URL, name: "Docmost" }, externalApp: { url: DOCMOST_URL, name: "Docmost" },
async onInit(ctx) { _syncServer = ctx.syncServer; },
feeds: [ feeds: [
{ id: "documents", name: "Documents", kind: "data", description: "Collaborative documents and wiki pages" }, { id: "documents", name: "Documents", kind: "data", description: "Collaborative documents and wiki pages" },
], ],

View File

@ -483,6 +483,17 @@ routes.get('/', (c) => {
// ── Module export ── // ── 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<ExchangeIntentsDoc>(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 = { export const exchangeModule: RSpaceModule = {
id: 'rexchange', id: 'rexchange',
name: 'rExchange', name: 'rExchange',

View File

@ -707,3 +707,44 @@ export const filesModule: RSpaceModule = {
{ label: "Upload Files", icon: "⬆️", description: "Add files to your space", type: 'create', href: '/{space}/rfiles' }, { 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<FilesDoc>(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);
}

View File

@ -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<FlowsDoc>(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 = { export const flowsModule: RSpaceModule = {
id: "rflows", id: "rflows",
name: "rFlows", name: "rFlows",

View File

@ -224,6 +224,19 @@ function seedTemplateForum(_space: string) {
console.log(`[Forum] Template seeded: 1 demo instance`); 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<ForumDoc>(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 = { export const forumModule: RSpaceModule = {
id: "rforum", id: "rforum",
name: "rForum", name: "rForum",

View File

@ -261,6 +261,22 @@ function seedTemplateGov(space: string) {
console.log(`[rGov] Template seeded for "${space}": 3 circuits (13 shapes + 9 arrows)`); 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<string, number> = {};
for (const shape of Object.values(doc.shapes as Record<string, any>)) {
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 ── // ── Module export ──
export const govModule: RSpaceModule = { export const govModule: RSpaceModule = {

View File

@ -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' }, { 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<MailboxDoc>(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);
}

View File

@ -7,15 +7,164 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; 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 routes = new Hono();
const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; 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<MapsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<MapsDoc>(), '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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(docId, `delete meeting point ${pointId}`, (d) => { delete d.savedMeetingPoints[pointId]; });
return c.json({ ok: true });
});
// ── Sync URL for client-side WebSocket connection ── // ── Sync URL for client-side WebSocket connection ──
routes.get("/api/sync-url", (c) => { routes.get("/api/sync-url", (c) => {
const wsUrl = process.env.MAPS_SYNC_URL || "wss://maps-sync.rspace.online"; 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<MapsDoc>(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 = { export const mapsModule: RSpaceModule = {
id: "rmaps", id: "rmaps",
name: "rMaps", name: "rMaps",
@ -307,9 +469,11 @@ export const mapsModule: RSpaceModule = {
canvasShapes: ["folk-map"], canvasShapes: ["folk-map"],
canvasToolIds: ["create_map"], canvasToolIds: ["create_map"],
scoping: { defaultScope: 'global', userConfigurable: false }, scoping: { defaultScope: 'global', userConfigurable: false },
docSchemas: [{ pattern: '{space}:maps:annotations', description: 'Map annotations, routes, and meeting points', init: mapsSchema.init }],
routes, routes,
landingPage: renderLanding, landingPage: renderLanding,
standaloneDomain: "rmaps.online", standaloneDomain: "rmaps.online",
async onInit(ctx) { _syncServer = ctx.syncServer; },
feeds: [ feeds: [
{ {
id: "locations", id: "locations",

View File

@ -11,6 +11,27 @@ import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/sh
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing"; 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<MeetsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<MeetsDoc>(), '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 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"; const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000";
@ -99,6 +120,48 @@ const MI_STYLES = `<style>
@media(max-width:600px){.mi-page{margin:1rem auto;padding:0 .75rem}.mi-cards{grid-template-columns:1fr}} @media(max-width:600px){.mi-page{margin:1rem auto;padding:0 .75rem}.mi-cards{grid-template-columns:1fr}}
</style>`; </style>`;
// ── 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<MeetsDoc>(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<MeetsDoc>(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<MeetsDoc>(docId, `delete meeting ${id}`, (d) => { delete d.meetings[id]; });
return c.json({ ok: true });
});
// ── Direct Jitsi lobby ── // ── Direct Jitsi lobby ──
routes.get("/meet", (c) => { routes.get("/meet", (c) => {
@ -568,6 +631,17 @@ function formatTimestamp(seconds: number): string {
return `${m}:${String(s).padStart(2, "0")}`; 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<MeetsDoc>(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 ── // ── Module export ──
export const meetsModule: RSpaceModule = { export const meetsModule: RSpaceModule = {
@ -577,6 +651,8 @@ export const meetsModule: RSpaceModule = {
description: "Video meetings powered by Jitsi", description: "Video meetings powered by Jitsi",
scoping: { defaultScope: "space", userConfigurable: false }, scoping: { defaultScope: "space", userConfigurable: false },
routes, routes,
docSchemas: [{ pattern: '{space}:meets:meetings', description: 'Meeting scheduling per space', init: meetsSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
landingPage: renderLanding, landingPage: renderLanding,
externalApp: { url: JITSI_URL, name: "Jitsi Meet" }, externalApp: { url: JITSI_URL, name: "Jitsi Meet" },
outputPaths: [ outputPaths: [

View File

@ -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<NetworkDoc>(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 = { export const networkModule: RSpaceModule = {
id: "rnetwork", id: "rnetwork",
name: "rNetwork", name: "rNetwork",

View File

@ -7,14 +7,121 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell, renderExternalAppShell } from "../../server/shell"; import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; 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 routes = new Hono();
const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284"; 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<PhotosDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<PhotosDoc>(), '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<PhotosDoc>(docId, `share album ${id}`, (d) => {
d.sharedAlbums[id] = { id, name, description, sharedBy: claims.sub || null, sharedAt: Date.now() };
});
const updated = _syncServer.getDoc<PhotosDoc>(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<PhotosDoc>(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<PhotosDoc>(docId, `annotate ${assetId}`, (d) => {
d.annotations[id] = { assetId, note, authorDid: (claims.did as string) || claims.sub || '', createdAt: Date.now() };
});
const updated = _syncServer.getDoc<PhotosDoc>(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<PhotosDoc>(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_API_KEY = process.env.RPHOTOS_API_KEY || "";
const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online"; 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<PhotosDoc>(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 = { export const photosModule: RSpaceModule = {
id: "rphotos", id: "rphotos",
name: "rPhotos", name: "rPhotos",
icon: "📸", icon: "📸",
description: "Community photo commons", description: "Community photo commons",
scoping: { defaultScope: 'global', userConfigurable: false }, scoping: { defaultScope: 'global', userConfigurable: false },
docSchemas: [{ pattern: '{space}:photos:albums', description: 'Shared albums and annotations per space', init: photosSchema.init }],
routes, routes,
landingPage: renderLanding, landingPage: renderLanding,
standaloneDomain: "rphotos.online", standaloneDomain: "rphotos.online",
externalApp: { url: IMMICH_PUBLIC_URL, name: "Immich" }, externalApp: { url: IMMICH_PUBLIC_URL, name: "Immich" },
async onInit(ctx) { _syncServer = ctx.syncServer; },
feeds: [ feeds: [
{ {
id: "rphotos", id: "rphotos",

View File

@ -20,6 +20,11 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing"; 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"; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts";
@ -181,6 +186,8 @@ function escapeAttr(s: string): string {
// ── Routes ── // ── Routes ──
let _syncServer: SyncServer | null = null;
const routes = new Hono(); const routes = new Hono();
// ── API: List available formats ── // ── 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<PubsDoc>(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<PubsDoc>(), '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<PubsDoc>(docId);
if (!doc) return c.json({ error: "Not found" }, 404);
_syncServer.changeDoc<PubsDoc>(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) ── // ── Page: Zine Generator (redirect to canvas with auto-spawn) ──
routes.get("/zine", (c) => { routes.get("/zine", (c) => {
const spaceSlug = c.req.param("space") || "personal"; 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<PubsDoc>(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 ── // ── Module export ──
export const pubsModule: RSpaceModule = { export const pubsModule: RSpaceModule = {
@ -705,6 +784,8 @@ export const pubsModule: RSpaceModule = {
description: "Drop in a document, get a pocket book", description: "Drop in a document, get a pocket book",
scoping: { defaultScope: 'global', userConfigurable: true }, scoping: { defaultScope: 'global', userConfigurable: true },
routes, routes,
docSchemas: [{ pattern: '{space}:pubs:drafts:{draftId}', description: 'One doc per publication draft', init: pubsDraftSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
publicWrite: true, publicWrite: true,
standaloneDomain: "rpubs.online", standaloneDomain: "rpubs.online",
landingPage: renderLanding, landingPage: renderLanding,

View File

@ -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' }, { 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<ScheduleDoc>(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,
}));
}

View File

@ -5,12 +5,103 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderExternalAppShell } from "../../server/shell"; import { renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } 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(); const routes = new Hono();
// ── Local-first helpers ──
function ensureSheetDoc(space: string, sheetId: string): SheetDoc {
const docId = sheetDocId(space, sheetId);
let doc = _syncServer!.getDoc<SheetDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SheetDoc>(), '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<SheetDoc>(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<SheetDoc>(), '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<SheetDoc>(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<SheetDoc>(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<SheetDoc>(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<SheetDoc>(docId, 'update cells', (d) => {
for (const [key, val] of Object.entries(cells as Record<string, any>)) {
d.cells[key] = { value: val.value || '', formula: val.formula || null, format: val.format || null, updatedAt: Date.now() };
}
});
return c.json({ ok: true });
});
// ── Routes ── // ── Routes ──
routes.get("/", (c) => { routes.get("/", (c) => {
@ -157,6 +248,21 @@ routes.get("/app", (c) => {
</html>`); </html>`);
}); });
// ── 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<SheetDoc>(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 ── // ── Module definition ──
export const sheetModule: RSpaceModule = { export const sheetModule: RSpaceModule = {
@ -165,11 +271,13 @@ export const sheetModule: RSpaceModule = {
icon: "\u{1F4CA}", icon: "\u{1F4CA}",
description: "Collaborative spreadsheets", description: "Collaborative spreadsheets",
scoping: { defaultScope: "space", userConfigurable: false }, scoping: { defaultScope: "space", userConfigurable: false },
docSchemas: [{ pattern: '{space}:sheet:sheets:{sheetId}', description: 'One doc per spreadsheet', init: sheetSchema.init }],
routes, routes,
externalApp: { externalApp: {
url: "/rsheet/app", url: "/rsheet/app",
name: "dSheet", name: "dSheet",
}, },
async onInit(ctx) { _syncServer = ctx.syncServer; },
outputPaths: [ outputPaths: [
{ {
path: "", path: "",

View File

@ -2347,3 +2347,32 @@ export const socialsModule: RSpaceModule = {
{ label: "Create a Thread", icon: "🧵", description: "Start a discussion thread", type: 'create', href: '/{space}/rsocials' }, { 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<SocialsDoc>(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,
}));
}

View File

@ -146,6 +146,23 @@ routes.get("/", async (c) => {
return c.html(html); 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<string, number> = {};
for (const shape of Object.values(doc.shapes as Record<string, any>)) {
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 = { export const canvasModule: RSpaceModule = {
id: "rspace", id: "rspace",
name: "rSpace", name: "rSpace",

View File

@ -857,6 +857,17 @@ routes.get("/:slug", async (c) => {
return c.html(result.html, result.status); 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<SplatScenesDoc>(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 ── // ── Module export ──
export const splatModule: RSpaceModule = { export const splatModule: RSpaceModule = {

View File

@ -19,6 +19,27 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing"; 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<SwagDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SwagDoc>(), '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(); 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<SwagDoc>(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<SwagDoc>(docId)!;
return c.json(updated.designs[id], 201);
});
// ── Page route: swag designer ── // ── Page route: swag designer ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; 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<SwagDoc>(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 = { export const swagModule: RSpaceModule = {
id: "rswag", id: "rswag",
name: "rSwag", name: "rSwag",
@ -981,6 +1039,8 @@ export const swagModule: RSpaceModule = {
description: "Design print-ready swag: stickers, posters, tees", description: "Design print-ready swag: stickers, posters, tees",
scoping: { defaultScope: 'global', userConfigurable: true }, scoping: { defaultScope: 'global', userConfigurable: true },
routes, routes,
docSchemas: [{ pattern: '{space}:swag:designs', description: 'Design catalog per space', init: swagSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
landingPage: renderLanding, landingPage: renderLanding,
standaloneDomain: "rswag.online", standaloneDomain: "rswag.online",
feeds: [ feeds: [

View File

@ -809,3 +809,33 @@ export const timeModule: RSpaceModule = {
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' }, { 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<CommitmentsDoc>(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",
}));
}

View File

@ -749,6 +749,19 @@ function seedTemplateTrips(space: string) {
console.log(`[Trips] Template seeded for "${space}": 1 trip, 2 destinations, 3 itinerary, 2 bookings`); 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<TripDoc>(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 = { export const tripsModule: RSpaceModule = {
id: "rtrips", id: "rtrips",
name: "rTrips", name: "rTrips",

View File

@ -11,8 +11,28 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; 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"; 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<TubeDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TubeDoc>(), '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(); const routes = new Hono();
// ── 360split config ── // ── 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<TubeDoc>(docId, `create playlist ${id}`, (d) => {
d.playlists[id] = { id, name, entries, createdBy: null, createdAt: Date.now() };
});
const updated = _syncServer.getDoc<TubeDoc>(docId)!;
return c.json(updated.playlists[id], 201);
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; 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<TubeDoc>(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 = { export const tubeModule: RSpaceModule = {
id: "rtube", id: "rtube",
name: "rTube", name: "rTube",
@ -430,6 +487,8 @@ export const tubeModule: RSpaceModule = {
description: "Community video hosting & live streaming", description: "Community video hosting & live streaming",
scoping: { defaultScope: 'global', userConfigurable: true }, scoping: { defaultScope: 'global', userConfigurable: true },
routes, routes,
docSchemas: [{ pattern: '{space}:tube:playlists', description: 'Playlists and watch party per space', init: tubeSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
landingPage: renderLanding, landingPage: renderLanding,
standaloneDomain: "rtube.online", standaloneDomain: "rtube.online",
feeds: [ feeds: [

View File

@ -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<VnbDoc>(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 ── // ── Module export ──
export const vnbModule: RSpaceModule = { export const vnbModule: RSpaceModule = {

View File

@ -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<ProposalDoc>(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 = { export const voteModule: RSpaceModule = {
id: "rvote", id: "rvote",
name: "rVote", name: "rVote",

View File

@ -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"))); 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 = { export const walletModule: RSpaceModule = {
id: "rwallet", id: "rwallet",
name: "rWallet", name: "rWallet",

View File

@ -43,6 +43,7 @@ import {
import type { SpaceAuthConfig } from "@encryptid/sdk/server"; import type { SpaceAuthConfig } from "@encryptid/sdk/server";
import { verifyToken, extractToken } from "./auth"; import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth"; import type { EncryptIDClaims } from "./auth";
import { createMcpRouter } from "./mcp-server";
const spaceAuthOpts = () => ({ const spaceAuthOpts = () => ({
getSpaceConfig, getSpaceConfig,
@ -528,6 +529,9 @@ app.route("/api/rtasks", checklistApiRoutes);
// ── Bug Report API ── // ── Bug Report API ──
app.route("/api/bug-report", bugReportRouter); 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) ── // ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes); app.route("/respond", magicLinkRoutes);

118
server/mcp-server.ts Normal file
View File

@ -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;
}

102
server/mcp-tools/_auth.ts Normal file
View File

@ -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<AccessResult> {
// 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 };
}

View File

@ -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<CrowdSurfDoc>(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<CrowdSurfDoc>(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),
}],
};
},
);
}

125
server/mcp-tools/rbnb.ts Normal file
View File

@ -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<BnbDoc>(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<BnbDoc>(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<BnbDoc>(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<BnbDoc>(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) }] };
},
);
}

View File

@ -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<BooksCatalogDoc>(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<BooksCatalogDoc>(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) }] };
},
);
}

219
server/mcp-tools/rcal.ts Normal file
View File

@ -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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(docId);
if (!doc?.events?.[event_id]) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }], isError: true };
}
syncServer.changeDoc<CalendarDoc>(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 }) }] };
},
);
}

151
server/mcp-tools/rcart.ts Normal file
View File

@ -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<CatalogDoc>(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<ShoppingCartIndexDoc>(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<ShoppingCartDoc>(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<GroupBuyDoc>(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) }] };
},
);
}

View File

@ -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<ChatsDirectoryDoc>(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<ChatChannelDoc>(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<ChatChannelDoc>(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) }] };
},
);
}

View File

@ -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<ChoicesDoc>(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<ChoicesDoc>(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<ChoicesDoc>(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<string, { label: string; totalScore: number; voteCount: number }> = {};
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),
}],
};
},
);
}

40
server/mcp-tools/rdata.ts Normal file
View File

@ -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<DataDoc>(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),
}],
};
},
);
}

View File

@ -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<DesignDoc>(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<DesignDoc>(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) }] };
},
);
}

39
server/mcp-tools/rdocs.ts Normal file
View File

@ -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<DocsDoc>(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) }] };
},
);
}

View File

@ -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<ExchangeIntentsDoc>(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<ExchangeTradesDoc>(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<ExchangePoolsDoc>(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<ExchangeReputationDoc>(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) }] };
},
);
}

161
server/mcp-tools/rfiles.ts Normal file
View File

@ -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<FilesDoc>(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<FilesDoc>(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<FilesDoc>(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) }] };
},
);
}

View File

@ -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<FlowsDoc>(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<FlowsDoc>(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),
}],
};
},
);
}

View File

@ -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<ForumDoc>(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<ForumDoc>(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) }] };
},
);
}

81
server/mcp-tools/rgov.ts Normal file
View File

@ -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),
}],
};
},
);
}

182
server/mcp-tools/rinbox.ts Normal file
View File

@ -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<MailboxDoc>(docId);
if (!doc?.mailbox) continue;
mailboxes.push({
id: doc.mailbox.id,
name: doc.mailbox.name,
slug: doc.mailbox.slug,
email: doc.mailbox.email,
threadCount: Object.keys(doc.threads || {}).length,
approvalCount: Object.keys(doc.approvals || {}).length,
});
}
return { content: [{ type: "text", text: JSON.stringify(mailboxes, null, 2) }] };
},
);
server.tool(
"rinbox_list_threads",
"List email threads in a mailbox (subjects only, no body content)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
mailbox_slug: z.string().optional().describe("Filter by mailbox slug (searches all if omitted)"),
status: z.string().optional().describe("Filter by status"),
search: z.string().optional().describe("Search in subject/from"),
limit: z.number().optional().describe("Max results (default 50)"),
},
async ({ space, token, mailbox_slug, status, search, limit }) => {
const access = await resolveAccess(token, space, false, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docIds = mailbox_slug
? findMailboxDocIds(syncServer, space).filter(id => id.endsWith(`:${mailbox_slug}`))
: findMailboxDocIds(syncServer, space);
let threads: Array<{
id: string; mailboxId: string; subject: string;
fromAddress: string | null; fromName: string | null;
status: string; isRead: boolean; isStarred: boolean;
receivedAt: number;
}> = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<MailboxDoc>(docId);
if (!doc?.threads) continue;
for (const t of Object.values(doc.threads)) {
threads.push({
id: t.id,
mailboxId: t.mailboxId,
subject: t.subject,
fromAddress: t.fromAddress,
fromName: t.fromName,
status: t.status,
isRead: t.isRead,
isStarred: t.isStarred,
receivedAt: t.createdAt,
});
}
}
if (status) threads = threads.filter(t => t.status === status);
if (search) {
const q = search.toLowerCase();
threads = threads.filter(t =>
t.subject.toLowerCase().includes(q) ||
(t.fromAddress && t.fromAddress.toLowerCase().includes(q)) ||
(t.fromName && t.fromName.toLowerCase().includes(q)),
);
}
threads.sort((a, b) => b.receivedAt - a.receivedAt);
threads = threads.slice(0, limit || 50);
return { content: [{ type: "text", text: JSON.stringify(threads, null, 2) }] };
},
);
server.tool(
"rinbox_get_thread",
"Get a full email thread including body text (omits bodyHtml for size)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
thread_id: z.string().describe("Thread ID"),
},
async ({ space, token, thread_id }) => {
const access = await resolveAccess(token, space, false, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
for (const docId of findMailboxDocIds(syncServer, space)) {
const doc = syncServer.getDoc<MailboxDoc>(docId);
const thread = doc?.threads?.[thread_id];
if (thread) {
// Omit bodyHtml to reduce payload size
const { bodyHtml, ...safe } = thread;
return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] };
}
}
return { content: [{ type: "text", text: JSON.stringify({ error: "Thread not found" }) }] };
},
);
server.tool(
"rinbox_list_approvals",
"List pending email approvals (draft reviews awaiting signatures)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
mailbox_slug: z.string().optional().describe("Filter by mailbox slug"),
},
async ({ space, token, mailbox_slug }) => {
const access = await resolveAccess(token, space, false, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docIds = mailbox_slug
? findMailboxDocIds(syncServer, space).filter(id => id.endsWith(`:${mailbox_slug}`))
: findMailboxDocIds(syncServer, space);
const approvals = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<MailboxDoc>(docId);
if (!doc?.approvals) continue;
for (const a of Object.values(doc.approvals)) {
if (a.status !== "pending") continue;
approvals.push({
id: a.id,
mailboxId: a.mailboxId,
subject: a.subject,
toAddresses: a.toAddresses,
authorId: a.authorId,
requiredSignatures: a.requiredSignatures,
currentSignatures: a.signatures?.length ?? 0,
createdAt: a.createdAt,
});
}
}
return { content: [{ type: "text", text: JSON.stringify(approvals, null, 2) }] };
},
);
}

79
server/mcp-tools/rmaps.ts Normal file
View File

@ -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<MapsDoc>(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<MapsDoc>(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<MapsDoc>(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) }] };
},
);
}

View File

@ -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<MeetsDoc>(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<MeetsDoc>(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) }] };
},
);
}

View File

@ -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<NetworkDoc>(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<NetworkDoc>(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<NetworkDoc>(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) }] };
},
);
}

232
server/mcp-tools/rnotes.ts Normal file
View File

@ -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<NotebookDoc>(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<NoteItem & { notebookTitle: string }> = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(docId);
if (!doc?.items?.[note_id]) continue;
syncServer.changeDoc<NotebookDoc>(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 };
},
);
}

View File

@ -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<PhotosDoc>(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<PhotosDoc>(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) }] };
},
);
}

81
server/mcp-tools/rpubs.ts Normal file
View File

@ -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<PubsDoc>(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<PubsDoc>(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),
}],
};
},
);
}

View File

@ -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<ScheduleDoc>(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<ScheduleDoc>(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<ScheduleDoc>(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<ScheduleDoc>(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<ScheduleDoc>(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 }) }] };
},
);
}

View File

@ -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<SheetDoc>(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<SheetDoc>(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),
}],
};
},
);
}

View File

@ -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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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 }) }] };
},
);
}

View File

@ -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<SplatScenesDoc>(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<SplatScenesDoc>(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) }] };
},
);
}

64
server/mcp-tools/rswag.ts Normal file
View File

@ -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<SwagDoc>(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<SwagDoc>(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) }] };
},
);
}

236
server/mcp-tools/rtasks.ts Normal file
View File

@ -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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(docId);
if (!doc?.tasks?.[task_id]) continue;
syncServer.changeDoc<BoardDoc>(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 };
},
);
}

176
server/mcp-tools/rtime.ts Normal file
View File

@ -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<CommitmentsDoc>(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<TasksDoc>(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<ExternalTimeLogsDoc>(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<CommitmentsDoc>(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<CommitmentsDoc>(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 }) }] };
},
);
}

156
server/mcp-tools/rtrips.ts Normal file
View File

@ -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<TripDoc>(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<TripDoc>(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<TripDoc>(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<TripDoc>(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<string, number> = {};
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),
}],
};
},
);
}

59
server/mcp-tools/rtube.ts Normal file
View File

@ -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<TubeDoc>(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<TubeDoc>(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) }] };
},
);
}

104
server/mcp-tools/rvnb.ts Normal file
View File

@ -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<VnbDoc>(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<VnbDoc>(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<VnbDoc>(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) }] };
},
);
}

132
server/mcp-tools/rvote.ts Normal file
View File

@ -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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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) }] };
},
);
}

177
server/mcp-tools/rwallet.ts Normal file
View File

@ -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<string, string> = {
"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<string, { name: string; symbol: string; decimals: number }> = {
"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<string, { did: string; label: string; balance: number }>;
}> = [];
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) }] };
},
);
}

View File

@ -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) }],
};
},
);
}

View File

@ -8,6 +8,38 @@
import { getUpcomingEventsForMI } from "../modules/rcal/mod"; import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentNotesForMI } from "../modules/rnotes/mod"; import { getRecentNotesForMI } from "../modules/rnotes/mod";
import { getRecentTasksForMI } from "../modules/rtasks/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 { export interface MiQueryResult {
ok: boolean; 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." }; 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: default:
return { ok: false, module, queryType, data: null, summary: `Module "${module}" does not support content queries.` }; return { ok: false, module, queryType, data: null, summary: `Module "${module}" does not support content queries.` };
} }

View File

@ -21,6 +21,38 @@ import type { MiAction } from "../lib/mi-actions";
import { getUpcomingEventsForMI } from "../modules/rcal/mod"; import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentNotesForMI } from "../modules/rnotes/mod"; import { getRecentNotesForMI } from "../modules/rnotes/mod";
import { getRecentTasksForMI } from "../modules/rtasks/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 { runAgenticLoop } from "./mi-agent";
import { generateImage, generateVideoViaFal } from "./mi-media"; import { generateImage, generateVideoViaFal } from "./mi-media";
import { queryModuleContent } from "./mi-data-queries"; import { queryModuleContent } from "./mi-data-queries";
@ -157,6 +189,37 @@ mi.post("/ask", async (c) => {
let calendarContext = ""; let calendarContext = "";
let notesContext = ""; let notesContext = "";
let tasksContext = ""; 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) { if (space) {
const upcoming = getUpcomingEventsForMI(space); const upcoming = getUpcomingEventsForMI(space);
if (upcoming.length > 0) { if (upcoming.length > 0) {
@ -186,6 +249,206 @@ mi.post("/ask", async (c) => {
); );
tasksContext = `\n- Open tasks:\n${lines.join("\n")}`; 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. 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. 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 ## 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 ## Guidelines
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. - 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":"rnotes","queryType":"recent","limit":5}]
[MI_ACTION:{"type":"query-content","module":"rtasks","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":"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". queryType can be: "recent", "summary", or "count".
Results will be provided in a follow-up message for you to incorporate into your response. Results will be provided in a follow-up message for you to incorporate into your response.