feat(rcal): MI calendar awareness, tags, saved views, MCP server

Phase 1: MI now knows the current date/time and upcoming events (14-day
lookahead, 5 events max) via direct Automerge read — no HTTP overhead.

Phase 2: Tags (string[] | null) on CalendarEvent for first-class filtering.
Saved views (named filter presets) with full CRUD API. Tag filter on
GET /api/events via comma-separated AND logic. Demo events seeded with tags.

Phase 3: Calendar MCP server (5 tools: cal_list_events, cal_get_event,
cal_list_sources, cal_list_views, cal_create_event) registered in .mcp.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 13:28:02 -07:00
parent f2c3245240
commit 2dd5e764cd
5 changed files with 237 additions and 4 deletions

12
.mcp.json Normal file
View File

@ -0,0 +1,12 @@
{
"mcpServers": {
"rspace-calendar": {
"command": "node",
"args": ["/home/jeffe/.claude/mcp-servers/calendar/index.js"],
"env": {
"RSPACE_BASE_URL": "http://localhost:3000",
"RSPACE_DEFAULT_SPACE": "demo"
}
}
}
}

View File

@ -17,7 +17,7 @@ import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { calendarSchema, calendarDocId } from './schemas'; import { calendarSchema, calendarDocId } from './schemas';
import type { CalendarDoc, CalendarEvent, CalendarSource, ScheduledItemMetadata } from './schemas'; import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata } from './schemas';
let _syncServer: SyncServer | null = null; let _syncServer: SyncServer | null = null;
@ -39,9 +39,17 @@ function ensureDoc(space: string): CalendarDoc {
d.meta.spaceSlug = space; d.meta.spaceSlug = space;
d.sources = {}; d.sources = {};
d.events = {}; d.events = {};
d.views = {};
}); });
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
} }
// Backward compat: old docs may lack views
if (!doc.views) {
_syncServer!.changeDoc<CalendarDoc>(docId, 'add views map', (d) => {
d.views = {};
});
doc = _syncServer!.getDoc<CalendarDoc>(docId)!;
}
return doc; return doc;
} }
@ -84,6 +92,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
virtual_platform: ev.virtualPlatform, virtual_platform: ev.virtualPlatform,
r_tool_source: ev.rToolSource, r_tool_source: ev.rToolSource,
r_tool_entity_id: ev.rToolEntityId, r_tool_entity_id: ev.rToolEntityId,
tags: ev.tags ?? null,
attendees: ev.attendees, attendees: ev.attendees,
attendee_count: ev.attendeeCount, attendee_count: ev.attendeeCount,
metadata: ev.metadata, metadata: ev.metadata,
@ -163,12 +172,14 @@ function seedDemoIfEmpty(space: string) {
locationId?: string; locationName?: string; locationId?: string; locationName?: string;
locationLat?: number; locationLng?: number; locationGranularity?: string; locationLat?: number; locationLng?: number; locationGranularity?: string;
isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string; isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string;
tags?: string[];
}> = [ }> = [
{ {
title: "rSpace Launch Party", title: "rSpace Launch Party",
desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.", desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.",
start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0), start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0),
sourceId: communityId, locationName: "Radiant Hall, Pittsburgh", sourceId: communityId, locationName: "Radiant Hall, Pittsburgh",
tags: ["launch", "community"],
}, },
{ {
title: "Provider Onboarding Workshop", title: "Provider Onboarding Workshop",
@ -176,6 +187,7 @@ function seedDemoIfEmpty(space: string) {
start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0), start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0),
sourceId: communityId, isVirtual: true, sourceId: communityId, isVirtual: true,
virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi", virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi",
tags: ["onboarding", "cosmolocal"],
}, },
{ {
title: "Weekly Community Standup", title: "Weekly Community Standup",
@ -183,12 +195,14 @@ function seedDemoIfEmpty(space: string) {
start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45), start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45),
sourceId: communityId, isVirtual: true, sourceId: communityId, isVirtual: true,
virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi", virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi",
tags: ["recurring", "community"],
}, },
{ {
title: "Sprint: Module Seeding & Polish", title: "Sprint: Module Seeding & Polish",
desc: "Focus sprint on populating demo data and improving UX across all modules.", desc: "Focus sprint on populating demo data and improving UX across all modules.",
start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0), start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0),
sourceId: sprintsId, allDay: true, sourceId: sprintsId, allDay: true,
tags: ["sprint", "dev"],
}, },
{ {
title: "rFlows Budget Review", title: "rFlows Budget Review",
@ -196,6 +210,7 @@ function seedDemoIfEmpty(space: string) {
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0), start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
sourceId: communityId, isVirtual: true, sourceId: communityId, isVirtual: true,
virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi", virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi",
tags: ["governance", "finance"],
}, },
{ {
title: "Cosmolocal Design Sprint", title: "Cosmolocal Design Sprint",
@ -204,6 +219,7 @@ function seedDemoIfEmpty(space: string) {
sourceId: sprintsId, sourceId: sprintsId,
locationId: berlinLocId, locationName: "Druckwerkstatt Berlin", locationId: berlinLocId, locationName: "Druckwerkstatt Berlin",
locationLat: 52.52, locationLng: 13.405, locationGranularity: "city", locationLat: 52.52, locationLng: 13.405, locationGranularity: "city",
tags: ["sprint", "design", "travel"],
}, },
{ {
title: "Q1 Retrospective", title: "Q1 Retrospective",
@ -211,6 +227,7 @@ function seedDemoIfEmpty(space: string) {
start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0), start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0),
sourceId: communityId, isVirtual: true, sourceId: communityId, isVirtual: true,
virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi", virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi",
tags: ["recurring", "community", "retrospective"],
}, },
]; ];
@ -244,6 +261,7 @@ function seedDemoIfEmpty(space: string) {
rToolEntityId: null, rToolEntityId: null,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: e.tags || null,
metadata: null, metadata: null,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,
@ -266,12 +284,21 @@ function seedDemoIfEmpty(space: string) {
routes.get("/api/events", async (c) => { routes.get("/api/events", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space; const dataSpace = c.get("effectiveSpace") || space;
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query(); const { start, end, source, search, rTool, rEntityId, upcoming, tags: tagsParam } = c.req.query();
const doc = ensureDoc(dataSpace); const doc = ensureDoc(dataSpace);
let events = Object.values(doc.events); let events = Object.values(doc.events);
// Apply filters // Apply filters
if (tagsParam) {
const requiredTags = tagsParam.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
if (requiredTags.length > 0) {
events = events.filter((e) => {
const eTags = (e.tags ?? []).map((t) => t.toLowerCase());
return requiredTags.every((rt) => eTags.includes(rt));
});
}
}
if (start) { if (start) {
const startMs = new Date(start).getTime(); const startMs = new Date(start).getTime();
events = events.filter((e) => e.startTime >= startMs); events = events.filter((e) => e.startTime >= startMs);
@ -322,7 +349,7 @@ routes.post("/api/events", async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name, const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id,
is_scheduled_item, provenance, item_preview } = body; is_scheduled_item, provenance, item_preview, tags: bodyTags } = body;
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400); if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
const docId = calendarDocId(dataSpace); const docId = calendarDocId(dataSpace);
@ -389,6 +416,7 @@ routes.post("/api/events", async (c) => {
rToolEntityId: r_tool_entity_id || null, rToolEntityId: r_tool_entity_id || null,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: Array.isArray(bodyTags) ? bodyTags : null,
metadata: metadata, metadata: metadata,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,
@ -509,6 +537,7 @@ routes.post("/api/import-ics", async (c) => {
rToolEntityId: null, rToolEntityId: null,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: null,
metadata: null, metadata: null,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,
@ -597,6 +626,7 @@ routes.patch("/api/events/:id", async (c) => {
location_name: 'locationName', location_name: 'locationName',
is_virtual: 'isVirtual', is_virtual: 'isVirtual',
virtual_url: 'virtualUrl', virtual_url: 'virtualUrl',
tags: 'tags',
metadata: 'metadata', metadata: 'metadata',
}; };
@ -843,6 +873,106 @@ routes.get("/api/context/:tool", async (c) => {
return c.json({ count: rows.length, results: rows }); return c.json({ count: rows.length, results: rows });
}); });
// ── API: Saved Views ──
function viewToRow(v: SavedCalendarView) {
return {
id: v.id,
name: v.name,
icon: v.icon,
filter: v.filter,
default_view_mode: v.defaultViewMode,
created_at: v.createdAt ? new Date(v.createdAt).toISOString() : null,
updated_at: v.updatedAt ? new Date(v.updatedAt).toISOString() : null,
};
}
routes.get("/api/views", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const doc = ensureDoc(dataSpace);
const views = Object.values(doc.views ?? {}).sort((a, b) => a.name.localeCompare(b.name));
return c.json({ count: views.length, results: views.map(viewToRow) });
});
routes.post("/api/views", 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); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json();
if (!body.name?.trim()) return c.json({ error: "name required" }, 400);
const docId = calendarDocId(dataSpace);
ensureDoc(dataSpace);
const viewId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<CalendarDoc>(docId, `create view ${viewId}`, (d) => {
d.views[viewId] = {
id: viewId,
name: body.name.trim(),
icon: body.icon || null,
filter: body.filter || {},
defaultViewMode: body.default_view_mode || null,
createdAt: now,
updatedAt: now,
};
});
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
return c.json(viewToRow(updated.views[viewId]), 201);
});
routes.patch("/api/views/: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); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = calendarDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.views?.[id]) return c.json({ error: "View not found" }, 404);
_syncServer!.changeDoc<CalendarDoc>(docId, `update view ${id}`, (d) => {
const v = d.views[id];
if (body.name !== undefined) v.name = body.name.trim();
if (body.icon !== undefined) v.icon = body.icon;
if (body.filter !== undefined) v.filter = body.filter;
if (body.default_view_mode !== undefined) v.defaultViewMode = body.default_view_mode;
v.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
return c.json(viewToRow(updated.views[id]));
});
routes.delete("/api/views/: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); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = calendarDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.views?.[id]) return c.json({ error: "View not found" }, 404);
_syncServer!.changeDoc<CalendarDoc>(docId, `delete view ${id}`, (d) => {
delete d.views[id];
});
return c.json({ ok: true });
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
@ -860,6 +990,47 @@ routes.get("/", (c) => {
})); }));
}); });
// ── MI Integration ──
/** Lean event shape for MI system prompt (minimal token footprint) */
export interface MICalendarEvent {
title: string;
start: string; // ISO date string
end: string; // ISO date string
allDay: boolean;
location: string | null;
isVirtual: boolean;
tags: string[] | null;
}
/**
* Read upcoming events directly from Automerge (no HTTP overhead).
* Returns [] if sync server not initialized or no events exist.
*/
export function getUpcomingEventsForMI(space: string, days = 14, limit = 5): MICalendarEvent[] {
if (!_syncServer) return [];
const docId = calendarDocId(space);
const doc = _syncServer.getDoc<CalendarDoc>(docId);
if (!doc) return [];
const now = Date.now();
const futureMs = now + days * 86400000;
return Object.values(doc.events)
.filter((e) => e.startTime >= now && e.startTime <= futureMs)
.sort((a, b) => a.startTime - b.startTime)
.slice(0, limit)
.map((e) => ({
title: e.title,
start: new Date(e.startTime).toISOString(),
end: e.endTime ? new Date(e.endTime).toISOString() : new Date(e.startTime).toISOString(),
allDay: e.allDay,
location: e.locationName || null,
isVirtual: e.isVirtual,
tags: e.tags ?? null,
}));
}
export const calModule: RSpaceModule = { export const calModule: RSpaceModule = {
id: "rcal", id: "rcal",
name: "rCal", name: "rCal",

View File

@ -52,11 +52,31 @@ export interface CalendarEvent {
rToolEntityId: string | null; rToolEntityId: string | null;
attendees: unknown[]; attendees: unknown[];
attendeeCount: number; attendeeCount: number;
tags: string[] | null;
metadata: unknown | null; metadata: unknown | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
export interface SavedCalendarViewFilter {
tags?: string[];
sourceIds?: string[];
search?: string;
locationName?: string;
isVirtual?: boolean;
rToolSource?: string;
}
export interface SavedCalendarView {
id: string;
name: string;
icon: string | null;
filter: SavedCalendarViewFilter;
defaultViewMode: string | null;
createdAt: number;
updatedAt: number;
}
export interface CalendarDoc { export interface CalendarDoc {
meta: { meta: {
module: string; module: string;
@ -67,6 +87,7 @@ export interface CalendarDoc {
}; };
sources: Record<string, CalendarSource>; sources: Record<string, CalendarSource>;
events: Record<string, CalendarEvent>; events: Record<string, CalendarEvent>;
views: Record<string, SavedCalendarView>;
} }
// ── Schema registration ── // ── Schema registration ──
@ -85,6 +106,7 @@ export const calendarSchema: DocSchema<CalendarDoc> = {
}, },
sources: {}, sources: {},
events: {}, events: {},
views: {},
}), }),
}; };

View File

@ -262,6 +262,7 @@ async function executeCalendarEvent(
rToolEntityId: job.id, rToolEntityId: job.id,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: null,
metadata: null, metadata: null,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,
@ -1087,6 +1088,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul
rToolEntityId: reminder.id, rToolEntityId: reminder.id,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: null,
metadata: null, metadata: null,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,
@ -1696,6 +1698,7 @@ async function executeWorkflowNode(
rToolEntityId: node.id, rToolEntityId: node.id,
attendees: [], attendees: [],
attendeeCount: 0, attendeeCount: 0,
tags: null,
metadata: null, metadata: null,
likelihood: null, likelihood: null,
createdAt: now, createdAt: now,

View File

@ -17,6 +17,7 @@ import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth"; import type { EncryptIDClaims } from "./auth";
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
import type { MiAction } from "../lib/mi-actions"; import type { MiAction } from "../lib/mi-actions";
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
const mi = new Hono(); const mi = new Hono();
@ -128,10 +129,34 @@ mi.post("/ask", async (c) => {
admin: "+ enable/disable modules, manage members", admin: "+ enable/disable modules, manage members",
}; };
// ── Build time + calendar context ──
const now = new Date();
const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const timeContext = `${weekdays[now.getUTCDay()]}, ${now.toISOString().split("T")[0]} at ${now.toISOString().split("T")[1].split(".")[0]} UTC`;
let calendarContext = "";
if (space) {
const upcoming = getUpcomingEventsForMI(space);
if (upcoming.length > 0) {
const lines = upcoming.map((e) => {
const date = e.allDay ? e.start.split("T")[0] : e.start;
let line = `- ${date}: ${e.title}`;
if (e.location) line += ` (${e.location})`;
else if (e.isVirtual) line += ` (virtual)`;
if (e.tags?.length) line += ` [${e.tags.join(", ")}]`;
return line;
});
calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`;
}
}
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.
You help users navigate, understand, and get the most out of the platform's apps (rApps). You help users navigate, understand, and get the most out of the platform's apps (rApps).
You understand the full context of what the user has open and can guide them through setup and usage. You understand the full context of what the user has open and can guide them through setup and usage.
## Current Date & Time
${timeContext}
## Your Caller's Role: ${callerRole} in space "${space || "none"}" ## Your Caller's Role: ${callerRole} in space "${space || "none"}"
- viewer: ${rolePermissions.viewer} - viewer: ${rolePermissions.viewer}
- member: ${rolePermissions.member} - member: ${rolePermissions.member}
@ -148,7 +173,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} ${contextSection}${calendarContext}
## 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.