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, formatCents, buildPaginationMeta, truncateIfNeeded
} from "../schemas/common.js";
import type { PcoDonation, PcoFund, ToolResult } from "../types.js";
const BASE = PCO_MODULES.giving;
export function registerGivingTools(server: McpServer): void {
// ─── List Donations ──────────────────────────────────────────────────────
server.registerTool(
"pco_list_donations",
{
title: "List Donations",
description: `List donations in Planning Center Giving.
Args:
- where_received_after (string, optional): Filter donations received after this date (YYYY-MM-DD)
- where_received_before (string, optional): Filter donations received before this date (YYYY-MM-DD)
- where_payment_method (string, optional): Filter by payment method (e.g., 'card', 'check', 'cash')
- where_payment_status (string, optional): Filter by status (e.g., 'succeeded', 'pending', 'failed')
- 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 donations with amount, payment method, date, and status.
Error: Returns "Error: ..." if the request fails.`,
inputSchema: z.object({
where_received_after: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe("Filter donations received after this date (YYYY-MM-DD)"),
where_received_before: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe("Filter donations received before this date (YYYY-MM-DD)"),
where_payment_method: z.string().max(50).optional()
.describe("Filter by payment method: 'card', 'check', 'cash', etc."),
where_payment_status: z.string().max(50).optional()
.describe("Filter by status: 'succeeded', 'pending', 'failed'"),
...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: "designations,labels",
};
if (params.where_received_after) queryParams["where[received_at][gte]"] = params.where_received_after;
if (params.where_received_before) queryParams["where[received_at][lte]"] = params.where_received_before;
if (params.where_payment_method) queryParams["where[payment_method]"] = params.where_payment_method;
if (params.where_payment_status) queryParams["where[payment_status]"] = params.where_payment_status;
const response = await apiGet<PcoDonation>(`${BASE}/donations`, queryParams);
const donations = ensureArray(response.data) as PcoDonation[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, donations.length, params.offset);
if (donations.length === 0) {
return { content: [{ type: "text", text: "No donations found matching your criteria." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = {
...meta,
donations: donations.map(d => ({ id: d.id, ...d.attributes })),
};
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Donations (${total} total, showing ${donations.length})`, ""];
for (const d of donations) {
const a = d.attributes;
const amount = formatCents(a.amount_cents, a.amount_currency ?? "USD");
lines.push(`## ${amount} — ${formatDate(a.received_at)} (ID: ${d.id})`);
if (a.payment_method) lines.push(`- **Payment Method**: ${a.payment_method}${a.payment_method_sub ? ` (${a.payment_method_sub})` : ""}`);
if (a.payment_brand) lines.push(`- **Card Brand**: ${a.payment_brand}${a.payment_last4 ? ` ****${a.payment_last4}` : ""}`);
if (a.payment_check_number) lines.push(`- **Check #**: ${a.payment_check_number}`);
if (a.payment_status) lines.push(`- **Status**: ${a.payment_status}`);
if (a.refunded) lines.push(`- ⚠️ **Refunded**: Yes`);
if (a.completed_at) lines.push(`- **Completed**: ${formatDateTime(a.completed_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 date filters or pagination to narrow results.");
return { content: [{ type: "text", text }] };
} catch (error) {
return { content: [{ type: "text", text: handleApiError(error) }] };
}
}
);
// ─── Get Donation ────────────────────────────────────────────────────────
server.registerTool(
"pco_get_donation",
{
title: "Get Donation",
description: `Get detailed information about a specific donation by ID.
Args:
- id (string): The donation ID
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns: Full donation record including amount, payment details, fund designations, and dates.
Error: Returns "Error: Resource not found" if the ID is invalid.`,
inputSchema: z.object({
id: z.string().min(1).describe("The donation ID"),
response_format: ResponseFormatSchema,
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params): Promise<ToolResult> => {
try {
const response = await apiGet<PcoDonation>(`${BASE}/donations/${params.id}`, {
include: "designations,labels,person",
});
const d = response.data as PcoDonation;
const a = d.attributes;
if (params.response_format === ResponseFormat.JSON) {
const output = { id: d.id, ...a };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const amount = formatCents(a.amount_cents, a.amount_currency ?? "USD");
const lines = [`# Donation: ${amount} (ID: ${d.id})`, ""];
if (a.received_at) lines.push(`- **Received**: ${formatDate(a.received_at)}`);
if (a.completed_at) lines.push(`- **Completed**: ${formatDateTime(a.completed_at)}`);
if (a.payment_method) lines.push(`- **Payment Method**: ${a.payment_method}`);
if (a.payment_method_sub) lines.push(`- **Sub-method**: ${a.payment_method_sub}`);
if (a.payment_brand) lines.push(`- **Card Brand**: ${a.payment_brand}${a.payment_last4 ? ` ****${a.payment_last4}` : ""}`);
if (a.payment_check_number) lines.push(`- **Check #**: ${a.payment_check_number}`);
if (a.payment_check_dated_at) lines.push(`- **Check Date**: ${formatDate(a.payment_check_dated_at)}`);
if (a.payment_status) lines.push(`- **Status**: ${a.payment_status}`);
if (a.fee_cents != null) lines.push(`- **Fee**: ${formatCents(a.fee_cents, a.fee_currency ?? "USD")}`);
if (a.refunded) lines.push(`- ⚠️ **Refunded**: Yes (started ${formatDate(a.refund_started_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 Funds ──────────────────────────────────────────────────────────
server.registerTool(
"pco_list_funds",
{
title: "List Giving Funds",
description: `List giving funds in Planning Center Giving.
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 funds with name, ledger code, description, visibility, and default status.
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<PcoFund>(`${BASE}/funds`, buildPaginationParams(params.limit, params.offset));
const funds = ensureArray(response.data) as PcoFund[];
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, funds.length, params.offset);
if (funds.length === 0) {
return { content: [{ type: "text", text: "No funds found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, funds: funds.map(f => ({ id: f.id, ...f.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Giving Funds (${total} total, showing ${funds.length})`, ""];
for (const f of funds) {
const a = f.attributes;
lines.push(`## ${a.name ?? "(unnamed)"} (ID: ${f.id})`);
if (a.ledger_code) lines.push(`- **Ledger Code**: ${a.ledger_code}`);
if (a.description) lines.push(`- **Description**: ${a.description}`);
if (a.visibility) lines.push(`- **Visibility**: ${a.visibility}`);
if (a.default) lines.push(`- ⭐ **Default Fund**`);
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 Batches ────────────────────────────────────────────────────────
server.registerTool(
"pco_list_donation_batches",
{
title: "List Donation Batches",
description: `List donation batches in Planning Center Giving.
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 batches with description, status, total, and commit 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(`${BASE}/batches`, buildPaginationParams(params.limit, params.offset));
const batches = ensureArray(response.data);
const total = getTotalCount(response);
const meta = buildPaginationMeta(total, batches.length, params.offset);
if (batches.length === 0) {
return { content: [{ type: "text", text: "No batches found." }] };
}
if (params.response_format === ResponseFormat.JSON) {
const output = { ...meta, batches: batches.map(b => ({ id: b.id, ...b.attributes })) };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
}
const lines = [`# Donation Batches (${total} total, showing ${batches.length})`, ""];
for (const b of batches) {
const a = b.attributes as Record<string, unknown>;
lines.push(`## ${String(a.description ?? "(unnamed)")} (ID: ${b.id})`);
if (a.status) lines.push(`- **Status**: ${a.status}`);
if (a.total_cents != null) lines.push(`- **Total**: ${formatCents(Number(a.total_cents))}`);
if (a.committed_at) lines.push(`- **Committed**: ${formatDate(String(a.committed_at))}`);
if (a.created_at) lines.push(`- **Created**: ${formatDate(String(a.created_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) }] };
}
}
);
}