import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { PCO_MODULES } from "../constants.js";
import {
apiGet, apiPost, apiPatch, handleApiError, buildPaginationParams,
getTotalCount, ensureArray
} from "../services/api.js";
import {
ResponseFormat, ResponseFormatSchema, PaginationSchema,
formatDate, formatDateTime, buildPaginationMeta, truncateIfNeeded
} from "../schemas/common.js";
import type { PcoPerson, PcoEmail, PcoPhoneNumber, PcoAddress, ToolResult } from "../types.js";
import { CHARACTER_LIMIT } from "../constants.js";
const BASE = PCO_MODULES.people;
export function registerPeopleTools(server: McpServer): void {
// ─── List / Search People ────────────────────────────────────────────────
server.registerTool(
"pco_list_people",
{
title: "List / Search People",
description: `Search for and list people in Planning Center People (PCO).
Supports filtering by name, status, and more. Returns person records with attributes.
Args:
- query (string, optional): Search by name (partial match on first or last name)
- where_first_name (string, optional): Filter by exact first name
- where_last_name (string, optional): Filter by exact last name
- where_status (string, optional): Filter by membership status (e.g. 'active', 'inactive')
- where_gender (string, optional): Filter by gender (e.g. 'M', 'F')
- where_child (boolean, optional): Filter to only children (true) or adults (false)
- include_emails (boolean, optional): Include email addresses in response
- include_phone_numbers (boolean, optional): Include phone numbers in response
- 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 people with name, status, gender, birthdate, created/updated dates.
Error: Returns "Error: ..." message if the request fails.`,
inputSchema: z.object({
query: z.string().max(200).optional()
.describe("Search by name (partial match)"),
where_first_name: z.string().max(100).optional()
.describe("Filter by exact first name"),
where_last_name: z.string().max(100).optional()
.describe("Filter by exact last name"),
where_status: z.enum(["active", "inactive"]).optional()
.describe("Filter by membership status"),
where_gender: z.string().max(10).optional()
.describe("Filter by gender (M or F)"),
where_child: z.boolean().optional()
.describe("Filter to children (true) or adults (false)"),
include_emails: z.boolean().default(false)
.describe("Include email addresses in response"),
include_phone_numbers: z.boolean().default(false)
.describe("Include phone numbers in response"),
...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["search_name"] = params.query;
if (params.where_first_name) queryParams["where[first_name]"] = params.where_first_name;
if (params.where_last_name) queryParams["where[last_name]"] = params.where_last_name;
if (params.where_status) queryParams["where[status]"] = params.where_status;
if (params.where_gender) queryParams["where[gender]"] = params.where_gender;
if (params.where_child !== undefined) queryParams["where[child]"] = params.where_child;
const includes: string[] = [];
if (params.include_emails) includes.push("emails");
if (params.include_phone_numbers) includes.push("phone_numbers");
if (includes.length) queryParams["include"] = includes.join(",");
const response = await apiGet<PcoPerson>(`${BASE}/people`, queryParams);
const people = ensureArray(response.data) as PcoPerson[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, people.length, params.offset);
const included = response.included ?? [];
if (people.length === 0) {
return {
content: [{ type: "text", text: "No people found matching your criteria." }],
};
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, people: people.map(p => ({ id: p.id, ...p.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
// Build email / phone maps from included
const emailMap: Record<string, string[]> = {};
const phoneMap: Record<string, string[]> = {};
for (const inc of included) {
const rel = (inc as unknown as Record<string, unknown>);
const personId = (rel.relationships as Record<string, {data?: {id?: string}}>)?.person?.data?.id;
if (!personId) continue;
if (inc.type === "Email") {
emailMap[personId] = emailMap[personId] ?? [];
emailMap[personId].push((inc.attributes as PcoEmail["attributes"]).address ?? "");
} else if (inc.type === "PhoneNumber") {
phoneMap[personId] = phoneMap[personId] ?? [];
phoneMap[personId].push((inc.attributes as PcoPhoneNumber["attributes"]).number ?? "");
}
}
const lines = [
`# People (${total} total, showing ${people.length})`,
"",
];
for (const p of people) {
const a = p.attributes;
const name = [a.first_name, a.last_name].filter(Boolean).join(" ") || "(unnamed)";
lines.push(`## ${name} (ID: ${p.id})`);
if (a.status) lines.push(`- **Status**: ${a.status}`);
if (a.gender) lines.push(`- **Gender**: ${a.gender}`);
if (a.birthdate) lines.push(`- **Birthdate**: ${formatDate(a.birthdate)}`);
if (a.membership) lines.push(`- **Membership**: ${a.membership}`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(a.created_at)}`);
if (emailMap[p.id]?.length) lines.push(`- **Emails**: ${emailMap[p.id].join(", ")}`);
if (phoneMap[p.id]?.length) lines.push(`- **Phones**: ${phoneMap[p.id].join(", ")}`);
lines.push("");
}
if (meta.has_more) {
lines.push(`*More results available — use offset ${meta.next_offset} to see the next page.*`);
}
const text = truncateIfNeeded(
lines.join("\n"),
CHARACTER_LIMIT,
"Use filters or increase offset to narrow results."
);
return { content: [{ type: "text", text }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Get Person ──────────────────────────────────────────────────────────
server.registerTool(
"pco_get_person",
{
title: "Get Person",
description: `Get detailed information about a single person by their PCO ID.
Args:
- id (string): The PCO person ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: Full person record including all attributes (name, gender, status, birthdate, etc.).
Error: Returns "Error: Resource not found" if the ID is invalid.`,
inputSchema: z.object({
id: z.string().min(1).describe("The PCO person ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoPerson>(`${BASE}/people/${params.id}`, {
include: "emails,phone_numbers,addresses",
});
const p = response.data as PcoPerson;
const included = response.included ?? [];
const a = p.attributes;
if (params.response_format === ResponseFormat.JSON) {
const output = {
id: p.id,
...a,
emails: included.filter(i => i.type === "Email").map(e => ({
address: (e.attributes as PcoEmail["attributes"]).address,
location: (e.attributes as PcoEmail["attributes"]).location,
primary: (e.attributes as PcoEmail["attributes"]).primary,
})),
phone_numbers: included.filter(i => i.type === "PhoneNumber").map(ph => ({
number: (ph.attributes as PcoPhoneNumber["attributes"]).number,
location: (ph.attributes as PcoPhoneNumber["attributes"]).location,
primary: (ph.attributes as PcoPhoneNumber["attributes"]).primary,
})),
addresses: included.filter(i => i.type === "Address").map(ad => ({
street: (ad.attributes as PcoAddress["attributes"]).street,
city: (ad.attributes as PcoAddress["attributes"]).city,
state: (ad.attributes as PcoAddress["attributes"]).state,
zip: (ad.attributes as PcoAddress["attributes"]).zip,
location: (ad.attributes as PcoAddress["attributes"]).location,
})),
};
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const name = [a.first_name, a.middle_name, a.last_name].filter(Boolean).join(" ") || "(unnamed)";
const lines = [`# ${name} (ID: ${p.id})`, ""];
if (a.status) lines.push(`- **Status**: ${a.status}`);
if (a.gender) lines.push(`- **Gender**: ${a.gender}`);
if (a.birthdate) lines.push(`- **Birthdate**: ${formatDate(a.birthdate)}`);
if (a.anniversary) lines.push(`- **Anniversary**: ${formatDate(a.anniversary)}`);
if (a.membership) lines.push(`- **Membership**: ${a.membership}`);
if (a.school_type) lines.push(`- **School Type**: ${a.school_type}`);
if (a.graduation_year) lines.push(`- **Graduation Year**: ${a.graduation_year}`);
if (a.medical_notes) lines.push(`- **Medical Notes**: ${a.medical_notes}`);
if (a.passed_background_check !== undefined) {
lines.push(`- **Background Check**: ${a.passed_background_check ? "Passed" : "Not passed"}`);
}
lines.push(`- **Created**: ${formatDate(a.created_at)}`);
lines.push(`- **Updated**: ${formatDate(a.updated_at)}`);
lines.push("");
const emails = included.filter(i => i.type === "Email");
if (emails.length) {
lines.push("## Email Addresses");
for (const e of emails) {
const ea = e.attributes as PcoEmail["attributes"];
lines.push(`- ${ea.address}${ea.location ? ` (${ea.location})` : ""}${ea.primary ? " ✓ primary" : ""}`);
}
lines.push("");
}
const phones = included.filter(i => i.type === "PhoneNumber");
if (phones.length) {
lines.push("## Phone Numbers");
for (const ph of phones) {
const pha = ph.attributes as PcoPhoneNumber["attributes"];
lines.push(`- ${pha.number}${pha.location ? ` (${pha.location})` : ""}${pha.primary ? " ✓ primary" : ""}`);
}
lines.push("");
}
const addresses = included.filter(i => i.type === "Address");
if (addresses.length) {
lines.push("## Addresses");
for (const ad of addresses) {
const ada = ad.attributes as PcoAddress["attributes"];
const addrLine = [ada.street, ada.city, ada.state, ada.zip].filter(Boolean).join(", ");
lines.push(`- ${addrLine}${ada.location ? ` (${ada.location})` : ""}${ada.primary ? " ✓ primary" : ""}`);
}
lines.push("");
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Create Person ───────────────────────────────────────────────────────
server.registerTool(
"pco_create_person",
{
title: "Create Person",
description: `Create a new person record in Planning Center People.
Args:
- first_name (string, required): First name
- last_name (string, required): Last name
- middle_name (string, optional): Middle name
- birthdate (string, optional): Birthdate in YYYY-MM-DD format
- gender (string, optional): Gender ('M' or 'F')
- status (string, optional): Status ('active' or 'inactive', default: 'active')
- child (boolean, optional): Whether this is a child record
- medical_notes (string, optional): Medical notes
Returns: The newly created person record with its assigned ID.
Error: Returns "Error: ..." if validation fails.`,
inputSchema: z.object({
first_name: z.string().min(1).max(100).describe("First name (required)"),
last_name: z.string().min(1).max(100).describe("Last name (required)"),
middle_name: z.string().max(100).optional().describe("Middle name"),
birthdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe("Birthdate in YYYY-MM-DD format"),
gender: z.enum(["M", "F"]).optional().describe("Gender: M or F"),
status: z.enum(["active", "inactive"]).default("active")
.describe("Status (default: 'active')"),
child: z.boolean().optional().describe("Whether this is a child record"),
medical_notes: z.string().max(1000).optional().describe("Medical notes"),
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const attributes: Record<string, unknown> = {
first_name: params.first_name,
last_name: params.last_name,
status: params.status,
};
if (params.middle_name !== undefined) attributes.middle_name = params.middle_name;
if (params.birthdate !== undefined) attributes.birthdate = params.birthdate;
if (params.gender !== undefined) attributes.gender = params.gender;
if (params.child !== undefined) attributes.child = params.child;
if (params.medical_notes !== undefined) attributes.medical_notes = params.medical_notes;
const body = { data: { type: "Person", attributes } };
const response = await apiPost<PcoPerson>(`${BASE}/people`, body);
const p = response.data as PcoPerson;
const a = p.attributes;
const name = [a.first_name, a.last_name].filter(Boolean).join(" ");
const lines = [
`✅ Person created successfully!`,
"",
`**Name**: ${name}`,
`**ID**: ${p.id}`,
`**Status**: ${a.status ?? "active"}`,
`**Created**: ${formatDateTime(a.created_at)}`,
];
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Update Person ───────────────────────────────────────────────────────
server.registerTool(
"pco_update_person",
{
title: "Update Person",
description: `Update an existing person record in Planning Center People.
Only the fields you provide will be updated (sparse update / PATCH semantics).
Args:
- id (string): PCO person ID to update
- first_name (string, optional): Updated first name
- last_name (string, optional): Updated last name
- middle_name (string, optional): Updated middle name
- birthdate (string, optional): Updated birthdate in YYYY-MM-DD format
- gender (string, optional): Updated gender ('M' or 'F')
- status (string, optional): Updated status ('active' or 'inactive')
- medical_notes (string, optional): Updated medical notes
Returns: Confirmation and the updated person record.
Error: Returns "Error: Resource not found" if the ID is invalid.`,
inputSchema: z.object({
id: z.string().min(1).describe("PCO person ID to update"),
first_name: z.string().min(1).max(100).optional().describe("Updated first name"),
last_name: z.string().min(1).max(100).optional().describe("Updated last name"),
middle_name: z.string().max(100).optional().describe("Updated middle name"),
birthdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe("Updated birthdate in YYYY-MM-DD format"),
gender: z.enum(["M", "F"]).optional().describe("Updated gender: M or F"),
status: z.enum(["active", "inactive"]).optional().describe("Updated status"),
medical_notes: z.string().max(1000).optional().describe("Updated medical notes"),
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const { id, ...rest } = params;
const attributes: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) attributes[k] = v;
}
if (Object.keys(attributes).length === 0) {
return {
content: [{
type: "text",
text: "Error: No fields to update. Provide at least one attribute to change.",
}],
};
}
const body = { data: { type: "Person", id, attributes } };
const response = await apiPatch<PcoPerson>(`${BASE}/people/${id}`, body);
const p = response.data as PcoPerson;
const a = p.attributes;
const name = [a.first_name, a.last_name].filter(Boolean).join(" ");
return {
content: [{
type: "text",
text: `✅ Person updated successfully!\n\n**Name**: ${name}\n**ID**: ${p.id}\n**Updated**: ${formatDateTime(a.updated_at)}`,
}],
};
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Person Emails ──────────────────────────────────────────────────
server.registerTool(
"pco_list_person_emails",
{
title: "List Person Emails",
description: `Get all email addresses for a specific person in PCO.
Args:
- person_id (string): The PCO person ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: List of email addresses with location and primary status.
Error: Returns "Error: Resource not found" if the person ID is invalid.`,
inputSchema: z.object({
person_id: z.string().min(1).describe("The PCO person ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoEmail>(`${BASE}/people/${params.person_id}/emails`);
const emails = ensureArray(response.data) as PcoEmail[];
if (emails.length === 0) {
return { content: [{ type: "text", text: "No email addresses found for this person." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = emails.map(e => ({ id: e.id, ...e.attributes }));
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: { emails: output },
};
}
const lines = [`# Email Addresses (Person ID: ${params.person_id})`, ""];
for (const e of emails) {
const a = e.attributes as PcoEmail["attributes"];
lines.push(`- **${a.address}**${a.location ? ` (${a.location})` : ""}${a.primary ? " ✓ primary" : ""}`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Person Phone Numbers ───────────────────────────────────────────
server.registerTool(
"pco_list_person_phone_numbers",
{
title: "List Person Phone Numbers",
description: `Get all phone numbers for a specific person in PCO.
Args:
- person_id (string): The PCO person ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: List of phone numbers with carrier, location, and primary status.
Error: Returns "Error: Resource not found" if the person ID is invalid.`,
inputSchema: z.object({
person_id: z.string().min(1).describe("The PCO person ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoPhoneNumber>(`${BASE}/people/${params.person_id}/phone_numbers`);
const phones = ensureArray(response.data) as PcoPhoneNumber[];
if (phones.length === 0) {
return { content: [{ type: "text", text: "No phone numbers found for this person." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = phones.map(ph => ({ id: ph.id, ...ph.attributes }));
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: { phone_numbers: output },
};
}
const lines = [`# Phone Numbers (Person ID: ${params.person_id})`, ""];
for (const ph of phones) {
const a = ph.attributes as PcoPhoneNumber["attributes"];
lines.push(`- **${a.number}**${a.location ? ` (${a.location})` : ""}${a.carrier ? ` [${a.carrier}]` : ""}${a.primary ? " ✓ primary" : ""}`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List Households ─────────────────────────────────────────────────────
server.registerTool(
"pco_list_households",
{
title: "List Households",
description: `List households in Planning Center People.
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 households with name, member count, and primary contact.
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}/households`, buildPaginationParams(params.limit, params.offset));
const households = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, households.length, params.offset);
if (households.length === 0) {
return { content: [{ type: "text", text: "No households found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, households: households.map(h => ({ id: h.id, ...h.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Households (${total} total, showing ${households.length})`, ""];
for (const h of households) {
const a = h.attributes as Record<string, unknown>;
lines.push(`## ${String(a.name ?? "(unnamed)")} (ID: ${h.id})`);
if (a.member_count != null) lines.push(`- **Members**: ${a.member_count}`);
if (a.primary_contact_name) lines.push(`- **Primary Contact**: ${a.primary_contact_name}`);
lines.push("");
}
if (meta.has_more) {
lines.push(`*More results available — use offset ${meta.next_offset} to see the next page.*`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── List PCO Lists ──────────────────────────────────────────────────────
server.registerTool(
"pco_list_people_lists",
{
title: "List People Lists",
description: `List smart lists (saved filters) in Planning Center People.
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 PCO Lists with name, description, status, and total 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}/lists`, buildPaginationParams(params.limit, params.offset));
const lists = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, lists.length, params.offset);
if (lists.length === 0) {
return { content: [{ type: "text", text: "No lists found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, lists: lists.map(l => ({ id: l.id, ...l.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# People Lists (${total} total, showing ${lists.length})`, ""];
for (const l of lists) {
const a = l.attributes as Record<string, unknown>;
lines.push(`## ${String(a.name ?? "(unnamed)")} (ID: ${l.id})`);
if (a.description) lines.push(` ${a.description}`);
if (a.status) lines.push(`- **Status**: ${a.status}`);
if (a.total_people != null) lines.push(`- **Total People**: ${a.total_people}`);
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) }] };
}
}
);
}