import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { PCO_MODULES, CHARACTER_LIMIT } from "../constants.js";
import {
apiGet, handleApiError, buildPaginationParams,
getTotalCount, ensureArray
} from "../services/api.js";
import {
ResponseFormat, ResponseFormatSchema, PaginationSchema,
formatDate, formatDateTime, buildPaginationMeta, truncateIfNeeded
} from "../schemas/common.js";
import type { PcoCalendarEvent, ToolResult } from "../types.js";
const BASE = PCO_MODULES.calendar;
export function registerCalendarTools(server: McpServer): void {
// ─── List Calendar Events ────────────────────────────────────────────────
server.registerTool(
"pco_list_calendar_events",
{
title: "List Calendar Events",
description: `List events in Planning Center Calendar.
Args:
- filter (string, optional): Filter events — 'approved', 'pending', 'rejected', 'future', 'past'
- limit (number): Max results (1-100, default 25)
- offset (number): Pagination offset (default 0)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: List of events with name, approval status, description, and visibility.
Error: Returns "Error: ..." if the request fails.`,
inputSchema: z.object({
filter: z.enum(["approved", "pending", "rejected", "future", "past"]).optional()
.describe("Filter: 'approved', 'pending', 'rejected', 'future', or 'past'"),
...PaginationSchema.shape,
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const queryParams: Record<string, string | number | boolean | undefined> = {
...buildPaginationParams(params.limit, params.offset),
};
if (params.filter) queryParams["filter"] = params.filter;
const response = await apiGet<PcoCalendarEvent>(`${BASE}/events`, queryParams);
const events = ensureArray(response.data) as PcoCalendarEvent[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, events.length, params.offset);
if (events.length === 0) {
return { content: [{ type: "text", text: "No calendar events found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, events: events.map(e => ({ id: e.id, ...e.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Calendar Events (${total} total, showing ${events.length})`, ""];
for (const ev of events) {
const a = ev.attributes;
lines.push(`## ${a.name ?? "(unnamed)"} (ID: ${ev.id})`);
if (a.approval_status) lines.push(`- **Status**: ${a.approval_status}`);
if (a.summary) lines.push(`- **Summary**: ${a.summary}`);
if (a.description) {
const desc = a.description.length > 150 ? a.description.slice(0, 150) + "…" : a.description;
lines.push(`- **Description**: ${desc}`);
}
if (a.featured !== undefined) lines.push(`- **Featured**: ${a.featured ? "Yes" : "No"}`);
if (a.visible_in_church_center !== undefined) lines.push(`- **Church Center**: ${a.visible_in_church_center ? "Visible" : "Hidden"}`);
if (a.registration_url) lines.push(`- **Registration**: ${a.registration_url}`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(a.created_at)}`);
lines.push("");
}
if (meta.has_more) {
lines.push(`*More results available — use offset ${meta.next_offset}.*`);
}
const text = truncateIfNeeded(lines.join("\n"), CHARACTER_LIMIT, "Use filter or pagination to narrow results.");
return { content: [{ type: "text", text }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Get Calendar Event ──────────────────────────────────────────────────
server.registerTool(
"pco_get_calendar_event",
{
title: "Get Calendar Event",
description: `Get detailed information about a specific calendar event.
Args:
- id (string): The event ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: Full event record with name, approval status, description, visibility, and registration info.
Error: Returns "Error: Resource not found" if the ID is invalid.`,
inputSchema: z.object({
id: z.string().min(1).describe("The calendar event ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoCalendarEvent>(`${BASE}/events/${params.id}`, {
include: "event_instances,tags",
});
const ev = response.data as PcoCalendarEvent;
const a = ev.attributes;
if (params.response_format === ResponseFormat.JSON) {
const output = { id: ev.id, ...a };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# ${a.name ?? "(unnamed)"} (ID: ${ev.id})`, ""];
if (a.approval_status) lines.push(`- **Status**: ${a.approval_status}`);
if (a.summary) lines.push(`- **Summary**: ${a.summary}`);
if (a.description) lines.push(`- **Description**: ${a.description}`);
if (a.featured !== undefined) lines.push(`- **Featured**: ${a.featured ? "Yes" : "No"}`);
if (a.visible_in_church_center !== undefined) lines.push(`- **Church Center**: ${a.visible_in_church_center ? "Visible" : "Hidden"}`);
if (a.registration_url) lines.push(`- **Registration**: ${a.registration_url}`);
if (a.image_url) lines.push(`- **Image**: ${a.image_url}`);
if (a.percent_approved != null) lines.push(`- **Approval**: ${a.percent_approved}%`);
if (a.created_at) lines.push(`- **Created**: ${formatDateTime(a.created_at)}`);
if (a.updated_at) lines.push(`- **Updated**: ${formatDateTime(a.updated_at)}`);
// Event instances from included
const included = response.included ?? [];
const instances = included.filter(i => i.type === "EventInstance");
if (instances.length) {
lines.push("", "## Instances");
for (const inst of instances) {
const ia = inst.attributes as Record<string, unknown>;
const start = ia.starts_at ? formatDateTime(String(ia.starts_at)) : "TBD";
const end = ia.ends_at ? formatDateTime(String(ia.ends_at)) : "TBD";
lines.push(`- ${start} → ${end}`);
}
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Event Instances ────────────────────────────────────────────────
server.registerTool(
"pco_list_event_instances",
{
title: "List Calendar Event Instances",
description: `List specific instances (occurrences) of a calendar event.
Args:
- event_id (string): The calendar event ID (get this from pco_list_calendar_events)
- limit (number): Max results (1-100, default 25)
- offset (number): Pagination offset (default 0)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: List of event instances with start/end times and location.
Error: Returns "Error: Resource not found" if the event ID is invalid.`,
inputSchema: z.object({
event_id: z.string().min(1).describe("The calendar event ID"),
...PaginationSchema.shape,
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet(
`${BASE}/events/${params.event_id}/event_instances`,
buildPaginationParams(params.limit, params.offset)
);
const instances = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, instances.length, params.offset);
if (instances.length === 0) {
return { content: [{ type: "text", text: "No instances found for this event." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, instances: instances.map(i => ({ id: i.id, ...i.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Event Instances (${total} total, showing ${instances.length})`, ""];
for (const inst of instances) {
const a = inst.attributes as Record<string, unknown>;
const start = a.starts_at ? formatDateTime(String(a.starts_at)) : "TBD";
const end = a.ends_at ? formatDateTime(String(a.ends_at)) : "TBD";
lines.push(`## ${start} (ID: ${inst.id})`);
lines.push(`- **Start**: ${start}`);
lines.push(`- **End**: ${end}`);
if (a.location) lines.push(`- **Location**: ${a.location}`);
lines.push("");
}
if (meta.has_more) {
lines.push(`*More results available — use offset ${meta.next_offset}.*`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Resource Rooms ──────────────────────────────────────────────────
server.registerTool(
"pco_list_calendar_resources",
{
title: "List Calendar Resources/Rooms",
description: `List facility resources (rooms, equipment) in Planning Center Calendar.
Args:
- limit (number): Max results (1-100, default 25)
- offset (number): Pagination offset (default 0)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: List of resources with name, kind, description, and quantity.
Error: Returns "Error: ..." if the request fails.`,
inputSchema: z.object({
...PaginationSchema.shape,
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet(`${BASE}/resources`, buildPaginationParams(params.limit, params.offset));
const resources = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, resources.length, params.offset);
if (resources.length === 0) {
return { content: [{ type: "text", text: "No resources found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, resources: resources.map(r => ({ id: r.id, ...r.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Calendar Resources (${total} total, showing ${resources.length})`, ""];
for (const r of resources) {
const a = r.attributes as Record<string, unknown>;
lines.push(`- **${String(a.name ?? "(unnamed)")}** (ID: ${r.id})${a.kind ? ` [${a.kind}]` : ""}${a.quantity != null ? ` — qty: ${a.quantity}` : ""}`);
if (a.description) lines.push(` *${a.description}*`);
}
if (meta.has_more) {
lines.push("", `*More results available — use offset ${meta.next_offset}.*`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
}