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 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<CalendarDoc>(docId, 'add views map', (d) => {
d.views = {};
});
doc = _syncServer!.getDoc<CalendarDoc>(docId)!;
}
return doc;
}
@ -84,6 +92,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
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<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 ──
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<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 = {
id: "rcal",
name: "rCal",

View File

@ -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<string, CalendarSource>;
events: Record<string, CalendarEvent>;
views: Record<string, SavedCalendarView>;
}
// ── Schema registration ──
@ -85,6 +106,7 @@ export const calendarSchema: DocSchema<CalendarDoc> = {
},
sources: {},
events: {},
views: {},
}),
};

View File

@ -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,

View File

@ -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.