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, buildPaginationMeta, truncateIfNeeded
} from "../schemas/common.js";
import type { PcoGroup, ToolResult } from "../types.js";
const BASE = PCO_MODULES.groups;
export function registerGroupsTools(server: McpServer): void {
// ─── List Groups ─────────────────────────────────────────────────────────
server.registerTool(
"pco_list_groups",
{
title: "List Groups",
description: `List groups in Planning Center Groups.
Args:
- query (string, optional): Search groups by name
- where_group_type_id (string, optional): Filter by group type ID
- 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 groups with name, description, location, schedule, and member count.
Error: Returns "Error: ..." if the request fails.`,
inputSchema: z.object({
query: z.string().max(200).optional().describe("Search groups by name"),
where_group_type_id: z.string().optional().describe("Filter by group type ID"),
...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.query) queryParams["where[name]"] = params.query;
if (params.where_group_type_id) queryParams["where[group_type_id]"] = params.where_group_type_id;
const response = await apiGet<PcoGroup>(`${BASE}/groups`, queryParams);
const groups = ensureArray(response.data) as PcoGroup[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, groups.length, params.offset);
if (groups.length === 0) {
return { content: [{ type: "text", text: "No groups found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, groups: groups.map(g => ({ id: g.id, ...g.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Groups (${total} total, showing ${groups.length})`, ""];
for (const g of groups) {
const a = g.attributes;
lines.push(`## ${a.name ?? "(unnamed)"} (ID: ${g.id})`);
if (a.description) {
const desc = a.description.length > 100 ? a.description.slice(0, 100) + "…" : a.description;
lines.push(` *${desc}*`);
}
if (a.location) lines.push(`- **Location**: ${a.location}`);
if (a.schedule) lines.push(`- **Schedule**: ${a.schedule}`);
if (a.memberships_count != null) lines.push(`- **Members**: ${a.memberships_count}`);
if (a.events_visibility) lines.push(`- **Events Visibility**: ${a.events_visibility}`);
if (a.archive_at) lines.push(`- ⚠️ **Archive Date**: ${formatDate(a.archive_at)}`);
if (a.public_church_center_web_url) lines.push(`- **URL**: ${a.public_church_center_web_url}`);
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 query or pagination to narrow results.");
return { content: [{ type: "text", text }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Get Group ───────────────────────────────────────────────────────────
server.registerTool(
"pco_get_group",
{
title: "Get Group",
description: `Get detailed information about a specific group by its ID.
Args:
- id (string): The group ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: Full group record including name, description, location, schedule, member count, and Church Center URL.
Error: Returns "Error: Resource not found" if the ID is invalid.`,
inputSchema: z.object({
id: z.string().min(1).describe("The group ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoGroup>(`${BASE}/groups/${params.id}`, {
include: "group_type,location",
});
const g = response.data as PcoGroup;
const a = g.attributes;
if (params.response_format === ResponseFormat.JSON) {
const output = { id: g.id, ...a };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# ${a.name ?? "(unnamed)"} (ID: ${g.id})`, ""];
if (a.description) lines.push(`*${a.description}*`, "");
if (a.location) lines.push(`- **Location**: ${a.location}`);
if (a.virtual_location_url) lines.push(`- **Virtual URL**: ${a.virtual_location_url}`);
if (a.schedule) lines.push(`- **Schedule**: ${a.schedule}`);
if (a.memberships_count != null) lines.push(`- **Members**: ${a.memberships_count}`);
if (a.events_visibility) lines.push(`- **Events Visibility**: ${a.events_visibility}`);
if (a.widget_status) lines.push(`- **Widget Status**: ${a.widget_status}`);
if (a.public_church_center_web_url) lines.push(`- **Church Center URL**: ${a.public_church_center_web_url}`);
if (a.archive_at) lines.push(`- ⚠️ **Archive Date**: ${formatDate(a.archive_at)}`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(a.created_at)}`);
if (a.updated_at) lines.push(`- **Updated**: ${formatDate(a.updated_at)}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Group Members ──────────────────────────────────────────────────
server.registerTool(
"pco_list_group_members",
{
title: "List Group Members",
description: `List members of a specific group in Planning Center Groups.
Args:
- group_id (string): The group ID (get this from pco_list_groups)
- filter (string, optional): Filter members — 'leader' for leaders only, 'member' for regular members
- 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 group members with their name, role, and join date.
Error: Returns "Error: Resource not found" if the group ID is invalid.`,
inputSchema: z.object({
group_id: z.string().min(1).describe("The group ID"),
filter: z.enum(["leader", "member"]).optional()
.describe("Filter by role: 'leader' or 'member'"),
...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),
include: "person",
};
if (params.filter) queryParams["filter"] = params.filter;
const response = await apiGet(`${BASE}/groups/${params.group_id}/memberships`, queryParams);
const memberships = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, memberships.length, params.offset);
// Build person name lookup from included
const included = response.included ?? [];
const personNames: Record<string, string> = {};
for (const inc of included) {
if (inc.type === "Person") {
const a = inc.attributes as Record<string, unknown>;
personNames[inc.id] = [a.first_name, a.last_name].filter(Boolean).join(" ") || "(unnamed)";
}
}
if (memberships.length === 0) {
return { content: [{ type: "text", text: "No members found for this group." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = {
...meta,
members: memberships.map(m => ({ id: m.id, ...m.attributes })),
};
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Group Members (${total} total, showing ${memberships.length})`, ""];
for (const m of memberships) {
const a = m.attributes as Record<string, unknown>;
const mAsUnknown = m as unknown as Record<string, Record<string, Record<string, {id?: string}>>>;
const personId = mAsUnknown.relationships?.person?.data?.id ?? "";
const name = personNames[personId] ?? String(a.first_name ?? "(unnamed)");
const role = String(a.role ?? "member");
lines.push(`- **${name}** — ${role}${a.joined_at ? ` (joined ${formatDate(String(a.joined_at))})` : ""}`);
}
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 Group Types ─────────────────────────────────────────────────────
server.registerTool(
"pco_list_group_types",
{
title: "List Group Types",
description: `List group types in Planning Center Groups.
Group types categorize groups (e.g., "Small Groups", "Bible Studies", "Recovery").
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 group types with name and group count.
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}/group_types`, buildPaginationParams(params.limit, params.offset));
const types = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, types.length, params.offset);
if (types.length === 0) {
return { content: [{ type: "text", text: "No group types found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, group_types: types.map(t => ({ id: t.id, ...t.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Group Types (${total} total, showing ${types.length})`, ""];
for (const t of types) {
const a = t.attributes as Record<string, unknown>;
lines.push(`- **${String(a.name ?? "(unnamed)")}** (ID: ${t.id})${a.groups_count != null ? ` — ${a.groups_count} groups` : ""}`);
}
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) }] };
}
}
);
}