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 { CHARACTER_LIMIT } from "../constants.js";
const BASE = PCO_MODULES.people;
export function registerPeopleTools(server) {
// βββ 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) => {
try {
const queryParams = {
...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 = [];
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(`${BASE}/people`, queryParams);
const people = ensureArray(response.data);
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 = {};
const phoneMap = {};
for (const inc of included) {
const rel = inc;
const personId = rel.relationships?.person?.data?.id;
if (!personId)
continue;
if (inc.type === "Email") {
emailMap[personId] = emailMap[personId] ?? [];
emailMap[personId].push(inc.attributes.address ?? "");
}
else if (inc.type === "PhoneNumber") {
phoneMap[personId] = phoneMap[personId] ?? [];
phoneMap[personId].push(inc.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) => {
try {
const response = await apiGet(`${BASE}/people/${params.id}`, {
include: "emails,phone_numbers,addresses",
});
const p = response.data;
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.address,
location: e.attributes.location,
primary: e.attributes.primary,
})),
phone_numbers: included.filter(i => i.type === "PhoneNumber").map(ph => ({
number: ph.attributes.number,
location: ph.attributes.location,
primary: ph.attributes.primary,
})),
addresses: included.filter(i => i.type === "Address").map(ad => ({
street: ad.attributes.street,
city: ad.attributes.city,
state: ad.attributes.state,
zip: ad.attributes.zip,
location: ad.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;
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;
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;
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) => {
try {
const attributes = {
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(`${BASE}/people`, body);
const p = response.data;
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) => {
try {
const { id, ...rest } = params;
const attributes = {};
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(`${BASE}/people/${id}`, body);
const p = response.data;
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) => {
try {
const response = await apiGet(`${BASE}/people/${params.person_id}/emails`);
const emails = ensureArray(response.data);
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;
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) => {
try {
const response = await apiGet(`${BASE}/people/${params.person_id}/phone_numbers`);
const phones = ensureArray(response.data);
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;
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) => {
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;
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) => {
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;
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) }] };
}
});
}
//# sourceMappingURL=people.js.map