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 { PcoServiceType, PcoPlan, PcoSong, PcoTeam, ToolResult } from "../types.js";
const BASE = PCO_MODULES.services;
export function registerServicesTools(server: McpServer): void {
// ─── List Service Types ──────────────────────────────────────────────────
server.registerTool(
"pco_list_service_types",
{
title: "List Service Types",
description: `List all service types in Planning Center Services.
Service types represent different worship services (e.g., "Sunday Morning", "Wednesday Night").
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 service types with name, frequency, and creation date.
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<PcoServiceType>(
`${BASE}/service_types`,
buildPaginationParams(params.limit, params.offset)
);
const types = ensureArray(response.data) as PcoServiceType[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, types.length, params.offset);
if (types.length === 0) {
return { content: [{ type: "text", text: "No service types found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, service_types: types.map(t => ({ id: t.id, ...t.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Service Types (${total} total, showing ${types.length})`, ""];
for (const t of types) {
const a = t.attributes;
lines.push(`## ${a.name ?? "(unnamed)"} (ID: ${t.id})`);
if (a.frequency) lines.push(`- **Frequency**: ${a.frequency}`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(a.created_at)}`);
if (a.archived_at) lines.push(`- ⚠️ **Archived**: ${formatDate(a.archived_at)}`);
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 Plans ──────────────────────────────────────────────────────────
server.registerTool(
"pco_list_plans",
{
title: "List Plans",
description: `List plans for a specific service type in Planning Center Services.
Args:
- service_type_id (string): The service type ID (get this from pco_list_service_types)
- filter (string, optional): Filter plans — 'future' (upcoming), 'past', 'no_dates', or 'undated'
- 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 plans with title, dates, people count, and items count.
Error: Returns "Error: Resource not found" if service_type_id is invalid.`,
inputSchema: z.object({
service_type_id: z.string().min(1).describe("The service type ID"),
filter: z.enum(["future", "past", "no_dates", "undated"]).optional()
.describe("Filter: 'future', 'past', 'no_dates', or 'undated'"),
...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<PcoPlan>(
`${BASE}/service_types/${params.service_type_id}/plans`,
queryParams
);
const plans = ensureArray(response.data) as PcoPlan[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, plans.length, params.offset);
if (plans.length === 0) {
return { content: [{ type: "text", text: "No plans found for this service type." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, plans: plans.map(p => ({ id: p.id, ...p.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Plans (${total} total, showing ${plans.length})`, ""];
for (const p of plans) {
const a = p.attributes;
const title = a.title || a.series_title || a.dates || "(untitled)";
lines.push(`## ${title} (ID: ${p.id})`);
if (a.dates) lines.push(`- **Date(s)**: ${a.dates}`);
if (a.series_title && a.title) lines.push(`- **Series**: ${a.series_title}`);
if (a.plan_people_count != null) lines.push(`- **People Scheduled**: ${a.plan_people_count}`);
if (a.items_count != null) lines.push(`- **Items**: ${a.items_count}`);
if (a.total_length != null) lines.push(`- **Total Length**: ${a.total_length} min`);
if (a.public !== undefined) lines.push(`- **Public**: ${a.public ? "Yes" : "No"}`);
if (a.planning_center_url) lines.push(`- **URL**: ${a.planning_center_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 filters or pagination to narrow results.");
return { content: [{ type: "text", text }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Get Plan ────────────────────────────────────────────────────────────
server.registerTool(
"pco_get_plan",
{
title: "Get Plan",
description: `Get detailed information about a specific plan in Planning Center Services.
Args:
- service_type_id (string): The service type ID
- plan_id (string): The plan ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: Full plan details including title, dates, team assignments, items, and notes count.
Error: Returns "Error: Resource not found" if IDs are invalid.`,
inputSchema: z.object({
service_type_id: z.string().min(1).describe("The service type ID"),
plan_id: z.string().min(1).describe("The plan ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoPlan>(
`${BASE}/service_types/${params.service_type_id}/plans/${params.plan_id}`
);
const p = response.data as PcoPlan;
const a = p.attributes;
if (params.response_format === ResponseFormat.JSON) {
const output = { id: p.id, ...a };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const title = a.title || a.series_title || a.dates || "(untitled)";
const lines = [`# Plan: ${title} (ID: ${p.id})`, ""];
if (a.dates) lines.push(`- **Date(s)**: ${a.dates}`);
if (a.series_title && a.title) lines.push(`- **Series**: ${a.series_title}`);
if (a.plan_people_count != null) lines.push(`- **People Scheduled**: ${a.plan_people_count}`);
if (a.needed_positions_count != null) lines.push(`- **Needed Positions**: ${a.needed_positions_count}`);
if (a.items_count != null) lines.push(`- **Items**: ${a.items_count}`);
if (a.total_length != null) lines.push(`- **Total Length**: ${a.total_length} min`);
if (a.service_time_count != null) lines.push(`- **Service Times**: ${a.service_time_count}`);
if (a.rehearsal_time_count != null) lines.push(`- **Rehearsal Times**: ${a.rehearsal_time_count}`);
if (a.plan_notes_count != null) lines.push(`- **Notes**: ${a.plan_notes_count}`);
if (a.public !== undefined) lines.push(`- **Public**: ${a.public ? "Yes" : "No"}`);
if (a.planning_center_url) lines.push(`- **URL**: ${a.planning_center_url}`);
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 Songs ──────────────────────────────────────────────────────────
server.registerTool(
"pco_list_songs",
{
title: "List Songs",
description: `List songs in the Planning Center Services song library.
Args:
- query (string, optional): Search songs by title
- hidden (boolean, optional): Filter by hidden status (true = hidden, false = visible only)
- 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 songs with title, author, CCLI number, and last scheduled date.
Error: Returns "Error: ..." if the request fails.`,
inputSchema: z.object({
query: z.string().max(200).optional().describe("Search songs by title"),
hidden: z.boolean().optional().describe("Filter by hidden status"),
...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[title]"] = params.query;
if (params.hidden !== undefined) queryParams["where[hidden]"] = params.hidden;
const response = await apiGet<PcoSong>(`${BASE}/songs`, queryParams);
const songs = ensureArray(response.data) as PcoSong[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, songs.length, params.offset);
if (songs.length === 0) {
return { content: [{ type: "text", text: "No songs found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, songs: songs.map(s => ({ id: s.id, ...s.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Songs (${total} total, showing ${songs.length})`, ""];
for (const s of songs) {
const a = s.attributes;
lines.push(`## ${a.title ?? "(untitled)"} (ID: ${s.id})`);
if (a.author) lines.push(`- **Author**: ${a.author}`);
if (a.copyright) lines.push(`- **Copyright**: ${a.copyright}`);
if (a.ccli_number) lines.push(`- **CCLI**: ${a.ccli_number}`);
if (a.themes) lines.push(`- **Themes**: ${a.themes}`);
if (a.last_scheduled_at) lines.push(`- **Last Scheduled**: ${formatDate(a.last_scheduled_at)}`);
if (a.hidden) lines.push(`- ⚠️ **Hidden**: Yes`);
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) }] };
}
}
);
// ─── List Teams ──────────────────────────────────────────────────────────
server.registerTool(
"pco_list_teams",
{
title: "List Teams",
description: `List teams for a specific service type in Planning Center Services.
Args:
- service_type_id (string): The service type ID (get this from pco_list_service_types)
- 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 teams with name, schedule type, and whether it's a rehearsal team.
Error: Returns "Error: Resource not found" if service_type_id is invalid.`,
inputSchema: z.object({
service_type_id: z.string().min(1).describe("The service type 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<PcoTeam>(
`${BASE}/service_types/${params.service_type_id}/teams`,
buildPaginationParams(params.limit, params.offset)
);
const teams = ensureArray(response.data) as PcoTeam[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, teams.length, params.offset);
if (teams.length === 0) {
return { content: [{ type: "text", text: "No teams found for this service type." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, teams: teams.map(t => ({ id: t.id, ...t.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Teams (${total} total, showing ${teams.length})`, ""];
for (const t of teams) {
const a = t.attributes;
lines.push(`## ${a.name ?? "(unnamed)"} (ID: ${t.id})`);
if (a.schedule_to) lines.push(`- **Schedule To**: ${a.schedule_to}`);
if (a.default_status) lines.push(`- **Default Status**: ${a.default_status}`);
if (a.rehearsal_team !== undefined) lines.push(`- **Rehearsal Team**: ${a.rehearsal_team ? "Yes" : "No"}`);
if (a.secure_team !== undefined) lines.push(`- **Secure Team**: ${a.secure_team ? "Yes" : "No"}`);
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 Series ─────────────────────────────────────────────────────────
server.registerTool(
"pco_list_series",
{
title: "List Series",
description: `List sermon/service series in Planning Center Services.
Args:
- service_type_id (string): The service type ID (get this from pco_list_service_types)
- 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 series with title, artwork, and plan count.
Error: Returns "Error: Resource not found" if service_type_id is invalid.`,
inputSchema: z.object({
service_type_id: z.string().min(1).describe("The service type 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}/service_types/${params.service_type_id}/series`,
buildPaginationParams(params.limit, params.offset)
);
const series = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, series.length, params.offset);
if (series.length === 0) {
return { content: [{ type: "text", text: "No series found for this service type." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, series: series.map(s => ({ id: s.id, ...s.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Series (${total} total, showing ${series.length})`, ""];
for (const s of series) {
const a = s.attributes as Record<string, unknown>;
lines.push(`## ${String(a.title ?? "(untitled)")} (ID: ${s.id})`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(String(a.created_at))}`);
if (a.updated_at) lines.push(`- **Updated**: ${formatDate(String(a.updated_at))}`);
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) }] };
}
}
);
}