From 2dd5e764cd6eac9086fe2854a1bec154b2904857 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 13:28:02 -0700 Subject: [PATCH] feat(rcal): MI calendar awareness, tags, saved views, MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .mcp.json | 12 +++ modules/rcal/mod.ts | 177 ++++++++++++++++++++++++++++++++++++++- modules/rcal/schemas.ts | 22 +++++ modules/rschedule/mod.ts | 3 + server/mi-routes.ts | 27 +++++- 5 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..7bd680c --- /dev/null +++ b/.mcp.json @@ -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" + } + } + } +} diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index d7e1cc0..a4f598b 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -17,7 +17,7 @@ import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; 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; @@ -39,9 +39,17 @@ function ensureDoc(space: string): CalendarDoc { d.meta.spaceSlug = space; d.sources = {}; d.events = {}; + d.views = {}; }); _syncServer!.setDoc(docId, doc); } + // Backward compat: old docs may lack views + if (!doc.views) { + _syncServer!.changeDoc(docId, 'add views map', (d) => { + d.views = {}; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -84,6 +92,7 @@ function eventToRow(ev: CalendarEvent, sources: Record) virtual_platform: ev.virtualPlatform, r_tool_source: ev.rToolSource, r_tool_entity_id: ev.rToolEntityId, + tags: ev.tags ?? null, attendees: ev.attendees, attendee_count: ev.attendeeCount, metadata: ev.metadata, @@ -163,12 +172,14 @@ function seedDemoIfEmpty(space: string) { locationId?: string; locationName?: string; locationLat?: number; locationLng?: number; locationGranularity?: string; isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string; + tags?: string[]; }> = [ { title: "rSpace Launch Party", desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.", start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0), sourceId: communityId, locationName: "Radiant Hall, Pittsburgh", + tags: ["launch", "community"], }, { title: "Provider Onboarding Workshop", @@ -176,6 +187,7 @@ function seedDemoIfEmpty(space: string) { start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi", + tags: ["onboarding", "cosmolocal"], }, { title: "Weekly Community Standup", @@ -183,12 +195,14 @@ function seedDemoIfEmpty(space: string) { start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi", + tags: ["recurring", "community"], }, { title: "Sprint: Module Seeding & Polish", desc: "Focus sprint on populating demo data and improving UX across all modules.", start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0), sourceId: sprintsId, allDay: true, + tags: ["sprint", "dev"], }, { title: "rFlows Budget Review", @@ -196,6 +210,7 @@ function seedDemoIfEmpty(space: string) { start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi", + tags: ["governance", "finance"], }, { title: "Cosmolocal Design Sprint", @@ -204,6 +219,7 @@ function seedDemoIfEmpty(space: string) { sourceId: sprintsId, locationId: berlinLocId, locationName: "Druckwerkstatt Berlin", locationLat: 52.52, locationLng: 13.405, locationGranularity: "city", + tags: ["sprint", "design", "travel"], }, { title: "Q1 Retrospective", @@ -211,6 +227,7 @@ function seedDemoIfEmpty(space: string) { start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi", + tags: ["recurring", "community", "retrospective"], }, ]; @@ -244,6 +261,7 @@ function seedDemoIfEmpty(space: string) { rToolEntityId: null, attendees: [], attendeeCount: 0, + tags: e.tags || null, metadata: null, likelihood: null, createdAt: now, @@ -266,12 +284,21 @@ function seedDemoIfEmpty(space: string) { routes.get("/api/events", async (c) => { const space = c.req.param("space") || "demo"; 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); let events = Object.values(doc.events); // 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) { const startMs = new Date(start).getTime(); events = events.filter((e) => e.startTime >= startMs); @@ -322,7 +349,7 @@ routes.post("/api/events", async (c) => { const body = await c.req.json(); 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_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); const docId = calendarDocId(dataSpace); @@ -389,6 +416,7 @@ routes.post("/api/events", async (c) => { rToolEntityId: r_tool_entity_id || null, attendees: [], attendeeCount: 0, + tags: Array.isArray(bodyTags) ? bodyTags : null, metadata: metadata, likelihood: null, createdAt: now, @@ -509,6 +537,7 @@ routes.post("/api/import-ics", async (c) => { rToolEntityId: null, attendees: [], attendeeCount: 0, + tags: null, metadata: null, likelihood: null, createdAt: now, @@ -597,6 +626,7 @@ routes.patch("/api/events/:id", async (c) => { location_name: 'locationName', is_virtual: 'isVirtual', virtual_url: 'virtualUrl', + tags: 'tags', metadata: 'metadata', }; @@ -843,6 +873,106 @@ routes.get("/api/context/:tool", async (c) => { 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(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(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(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(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(docId, `delete view ${id}`, (d) => { + delete d.views[id]; + }); + return c.json({ ok: true }); +}); + // ── Page route ── routes.get("/", (c) => { 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(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 = { id: "rcal", name: "rCal", diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts index 96883e3..a2dfd7a 100644 --- a/modules/rcal/schemas.ts +++ b/modules/rcal/schemas.ts @@ -52,11 +52,31 @@ export interface CalendarEvent { rToolEntityId: string | null; attendees: unknown[]; attendeeCount: number; + tags: string[] | null; metadata: unknown | null; createdAt: 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 { meta: { module: string; @@ -67,6 +87,7 @@ export interface CalendarDoc { }; sources: Record; events: Record; + views: Record; } // ── Schema registration ── @@ -85,6 +106,7 @@ export const calendarSchema: DocSchema = { }, sources: {}, events: {}, + views: {}, }), }; diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 8e3156f..a85ec1b 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -262,6 +262,7 @@ async function executeCalendarEvent( rToolEntityId: job.id, attendees: [], attendeeCount: 0, + tags: null, metadata: null, likelihood: null, createdAt: now, @@ -1087,6 +1088,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul rToolEntityId: reminder.id, attendees: [], attendeeCount: 0, + tags: null, metadata: null, likelihood: null, createdAt: now, @@ -1696,6 +1698,7 @@ async function executeWorkflowNode( rToolEntityId: node.id, attendees: [], attendeeCount: 0, + tags: null, metadata: null, likelihood: null, createdAt: now, diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 3202538..0830687 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -17,6 +17,7 @@ import { verifyToken, extractToken } from "./auth"; import type { EncryptIDClaims } from "./auth"; import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; import type { MiAction } from "../lib/mi-actions"; +import { getUpcomingEventsForMI } from "../modules/rcal/mod"; const mi = new Hono(); @@ -128,10 +129,34 @@ mi.post("/ask", async (c) => { 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. 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. +## Current Date & Time +${timeContext} + ## Your Caller's Role: ${callerRole} in space "${space || "none"}" - viewer: ${rolePermissions.viewer} - 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. ## Current Context -${contextSection} +${contextSection}${calendarContext} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.