@nightsquawktech/appfolio-mcp-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMMERCIAL.md +35 -0
- package/LICENSE +661 -0
- package/README.md +127 -0
- package/dist/catalog/index.json +1511 -0
- package/dist/catalog/report-spec.js +27 -0
- package/dist/catalog/reports/account_totals.json +112 -0
- package/dist/catalog/reports/additional_fees.json +73 -0
- package/dist/catalog/reports/aged_payables_summary.json +124 -0
- package/dist/catalog/reports/aged_receivables_detail.json +132 -0
- package/dist/catalog/reports/amenities_by_property.json +73 -0
- package/dist/catalog/reports/annual_budget_comparative.json +138 -0
- package/dist/catalog/reports/annual_budget_forecast.json +125 -0
- package/dist/catalog/reports/appfolio_stack_usage.json +112 -0
- package/dist/catalog/reports/association_work_order.json +232 -0
- package/dist/catalog/reports/balance_sheet.json +124 -0
- package/dist/catalog/reports/balance_sheet_comparative.json +126 -0
- package/dist/catalog/reports/balance_sheet_comparison.json +113 -0
- package/dist/catalog/reports/bank_account_association.json +94 -0
- package/dist/catalog/reports/bill_detail.json +183 -0
- package/dist/catalog/reports/board_of_directors.json +87 -0
- package/dist/catalog/reports/budget_comparative.json +162 -0
- package/dist/catalog/reports/budget_comparison.json +141 -0
- package/dist/catalog/reports/cancelled_processes.json +122 -0
- package/dist/catalog/reports/cash_flow.json +158 -0
- package/dist/catalog/reports/cash_flow_comparison.json +158 -0
- package/dist/catalog/reports/cash_flow_detail.json +129 -0
- package/dist/catalog/reports/charge_detail.json +198 -0
- package/dist/catalog/reports/chart_of_accounts.json +22 -0
- package/dist/catalog/reports/check_register.json +181 -0
- package/dist/catalog/reports/check_register_detail.json +181 -0
- package/dist/catalog/reports/completed_processes.json +117 -0
- package/dist/catalog/reports/delinquency.json +164 -0
- package/dist/catalog/reports/delinquency_as_of.json +155 -0
- package/dist/catalog/reports/deposit_register.json +51 -0
- package/dist/catalog/reports/dues_roll.json +89 -0
- package/dist/catalog/reports/dues_roll_itemized.json +113 -0
- package/dist/catalog/reports/eligible_debt_summary.json +73 -0
- package/dist/catalog/reports/email_delivery_errors.json +104 -0
- package/dist/catalog/reports/expense_distribution.json +109 -0
- package/dist/catalog/reports/expense_register.json +149 -0
- package/dist/catalog/reports/fixed_assets.json +111 -0
- package/dist/catalog/reports/flows_assigned_tasks.json +103 -0
- package/dist/catalog/reports/fund_balance_sheet.json +113 -0
- package/dist/catalog/reports/fund_balance_sheet_active_funds.json +113 -0
- package/dist/catalog/reports/fund_income_statement.json +132 -0
- package/dist/catalog/reports/general_ledger.json +166 -0
- package/dist/catalog/reports/gross_potential_rent_enhanced.json +102 -0
- package/dist/catalog/reports/guest_card_inquiries.json +177 -0
- package/dist/catalog/reports/guest_cards.json +161 -0
- package/dist/catalog/reports/historical_advertised_rent.json +92 -0
- package/dist/catalog/reports/homeowner_delinquency.json +143 -0
- package/dist/catalog/reports/homeowner_delinquency_as_of.json +161 -0
- package/dist/catalog/reports/homeowner_directory.json +126 -0
- package/dist/catalog/reports/homeowner_ledger.json +59 -0
- package/dist/catalog/reports/homeowner_prepayment_balance.json +127 -0
- package/dist/catalog/reports/homeowner_resale.json +107 -0
- package/dist/catalog/reports/homeowner_vehicle_info.json +104 -0
- package/dist/catalog/reports/import_variances.json +41 -0
- package/dist/catalog/reports/in_progress_workflows.json +181 -0
- package/dist/catalog/reports/inactive_guest_cards.json +134 -0
- package/dist/catalog/reports/income_register.json +131 -0
- package/dist/catalog/reports/income_statement.json +130 -0
- package/dist/catalog/reports/income_statement_comparative.json +140 -0
- package/dist/catalog/reports/income_statement_comparison.json +135 -0
- package/dist/catalog/reports/income_statement_date_range.json +134 -0
- package/dist/catalog/reports/inspection_detail.json +107 -0
- package/dist/catalog/reports/insurance_enforcement.json +22 -0
- package/dist/catalog/reports/insurance_usage.json +92 -0
- package/dist/catalog/reports/inventory_status.json +73 -0
- package/dist/catalog/reports/inventory_usage.json +131 -0
- package/dist/catalog/reports/keys_detail.json +131 -0
- package/dist/catalog/reports/late_fee_policy_comparison.json +135 -0
- package/dist/catalog/reports/lease_expiration_detail.json +147 -0
- package/dist/catalog/reports/lease_expiration_summary.json +95 -0
- package/dist/catalog/reports/lease_history.json +147 -0
- package/dist/catalog/reports/leasing_agent_performance.json +54 -0
- package/dist/catalog/reports/leasing_funnel_performance.json +113 -0
- package/dist/catalog/reports/leasing_summary.json +92 -0
- package/dist/catalog/reports/loans.json +95 -0
- package/dist/catalog/reports/occupancy_custom_fields.json +73 -0
- package/dist/catalog/reports/occupancy_summary.json +95 -0
- package/dist/catalog/reports/owner1099.json +135 -0
- package/dist/catalog/reports/owner1099_detail.json +156 -0
- package/dist/catalog/reports/owner_custom_fields.json +73 -0
- package/dist/catalog/reports/owner_directory.json +101 -0
- package/dist/catalog/reports/owner_leasing.json +115 -0
- package/dist/catalog/reports/owner_withholdings.json +170 -0
- package/dist/catalog/reports/payment_plans.json +128 -0
- package/dist/catalog/reports/premium_leads_billing_detail.json +70 -0
- package/dist/catalog/reports/project_budget_detail.json +56 -0
- package/dist/catalog/reports/project_directory.json +144 -0
- package/dist/catalog/reports/property_budget.json +83 -0
- package/dist/catalog/reports/property_custom_fields.json +73 -0
- package/dist/catalog/reports/property_directory.json +73 -0
- package/dist/catalog/reports/property_group_directory.json +85 -0
- package/dist/catalog/reports/property_performance.json +115 -0
- package/dist/catalog/reports/property_staff_assignments.json +73 -0
- package/dist/catalog/reports/prospect_source_tracking.json +92 -0
- package/dist/catalog/reports/purchase_order.json +163 -0
- package/dist/catalog/reports/receivables_activity.json +138 -0
- package/dist/catalog/reports/renewal_summary.json +134 -0
- package/dist/catalog/reports/rent_roll.json +114 -0
- package/dist/catalog/reports/rent_roll_commercial.json +106 -0
- package/dist/catalog/reports/rent_roll_itemized.json +125 -0
- package/dist/catalog/reports/rentable_items.json +97 -0
- package/dist/catalog/reports/rental_applications.json +118 -0
- package/dist/catalog/reports/renter_directory.json +60 -0
- package/dist/catalog/reports/resident_financial_activity.json +107 -0
- package/dist/catalog/reports/screening_assessments.json +104 -0
- package/dist/catalog/reports/screening_usage.json +92 -0
- package/dist/catalog/reports/security_deposit_funds_detail.json +141 -0
- package/dist/catalog/reports/showings.json +123 -0
- package/dist/catalog/reports/student_housing_rent_roll_by_bed.json +125 -0
- package/dist/catalog/reports/surveys_summary.json +118 -0
- package/dist/catalog/reports/tenant_debt_collections_status.json +90 -0
- package/dist/catalog/reports/tenant_directory.json +120 -0
- package/dist/catalog/reports/tenant_ledger.json +72 -0
- package/dist/catalog/reports/tenant_tickler.json +102 -0
- package/dist/catalog/reports/tenant_transactions_summary.json +97 -0
- package/dist/catalog/reports/tenant_unpaid_charges_summary.json +147 -0
- package/dist/catalog/reports/tenant_vehicle_info.json +107 -0
- package/dist/catalog/reports/trial_balance.json +104 -0
- package/dist/catalog/reports/trial_balance_by_property.json +104 -0
- package/dist/catalog/reports/trust_account_balance.json +116 -0
- package/dist/catalog/reports/trust_account_balance_detail.json +112 -0
- package/dist/catalog/reports/twelve_month_cash_flow.json +157 -0
- package/dist/catalog/reports/twelve_month_income_statement.json +147 -0
- package/dist/catalog/reports/unit_custom_fields.json +73 -0
- package/dist/catalog/reports/unit_directory.json +80 -0
- package/dist/catalog/reports/unit_inspection.json +95 -0
- package/dist/catalog/reports/unit_turn_detail.json +104 -0
- package/dist/catalog/reports/unit_vacancy.json +143 -0
- package/dist/catalog/reports/unpaid_balances_by_month.json +181 -0
- package/dist/catalog/reports/upcoming_activities.json +142 -0
- package/dist/catalog/reports/vendor1099.json +84 -0
- package/dist/catalog/reports/vendor_custom_fields.json +22 -0
- package/dist/catalog/reports/vendor_directory.json +134 -0
- package/dist/catalog/reports/vendor_ledger.json +122 -0
- package/dist/catalog/reports/vendor_ledger_enhanced.json +122 -0
- package/dist/catalog/reports/violation_detail.json +122 -0
- package/dist/catalog/reports/work_order.json +254 -0
- package/dist/catalog/reports/work_order_labor_summary.json +148 -0
- package/dist/catalog-db/endpoint-spec.js +22 -0
- package/dist/catalog-db/endpoints/delete_inspections_inspectionid.json +21 -0
- package/dist/catalog-db/endpoints/delete_inventories_id.json +21 -0
- package/dist/catalog-db/endpoints/delete_properties_propertyid_marketing_photos_marketingphotoid.json +27 -0
- package/dist/catalog-db/endpoints/delete_properties_propertyid_photos_photoid.json +27 -0
- package/dist/catalog-db/endpoints/delete_units_unitid_photos_photoid.json +27 -0
- package/dist/catalog-db/endpoints/delete_work_orders_workorderid_attachments_attachmentid.json +27 -0
- package/dist/catalog-db/endpoints/get_bank_accounts.json +58 -0
- package/dist/catalog-db/endpoints/get_bills.json +185 -0
- package/dist/catalog-db/endpoints/get_ca_associations.json +106 -0
- package/dist/catalog-db/endpoints/get_ca_bank_balances.json +101 -0
- package/dist/catalog-db/endpoints/get_ca_board_members.json +146 -0
- package/dist/catalog-db/endpoints/get_ca_homeowner_ledgers.json +70 -0
- package/dist/catalog-db/endpoints/get_ca_homeowners.json +151 -0
- package/dist/catalog-db/endpoints/get_ca_renters.json +132 -0
- package/dist/catalog-db/endpoints/get_ca_rules.json +76 -0
- package/dist/catalog-db/endpoints/get_ca_units.json +130 -0
- package/dist/catalog-db/endpoints/get_ca_violations.json +119 -0
- package/dist/catalog-db/endpoints/get_charges.json +76 -0
- package/dist/catalog-db/endpoints/get_corporate_entities.json +88 -0
- package/dist/catalog-db/endpoints/get_custom_fields.json +120 -0
- package/dist/catalog-db/endpoints/get_delinquent_charges.json +88 -0
- package/dist/catalog-db/endpoints/get_gl_accounts.json +115 -0
- package/dist/catalog-db/endpoints/get_gl_details.json +154 -0
- package/dist/catalog-db/endpoints/get_inventories.json +131 -0
- package/dist/catalog-db/endpoints/get_inventory_locations.json +70 -0
- package/dist/catalog-db/endpoints/get_jobs.json +79 -0
- package/dist/catalog-db/endpoints/get_journal_entries.json +137 -0
- package/dist/catalog-db/endpoints/get_late_fee_policies.json +148 -0
- package/dist/catalog-db/endpoints/get_leads.json +248 -0
- package/dist/catalog-db/endpoints/get_leads_notes.json +83 -0
- package/dist/catalog-db/endpoints/get_leases.json +70 -0
- package/dist/catalog-db/endpoints/get_leases_renewal_pricings.json +52 -0
- package/dist/catalog-db/endpoints/get_listings.json +259 -0
- package/dist/catalog-db/endpoints/get_occupancies_documents.json +70 -0
- package/dist/catalog-db/endpoints/get_owner_groups.json +230 -0
- package/dist/catalog-db/endpoints/get_owners.json +258 -0
- package/dist/catalog-db/endpoints/get_owners_attachments.json +82 -0
- package/dist/catalog-db/endpoints/get_owners_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_owners_ownerid.json +247 -0
- package/dist/catalog-db/endpoints/get_payables.json +138 -0
- package/dist/catalog-db/endpoints/get_portfolios.json +70 -0
- package/dist/catalog-db/endpoints/get_properties.json +267 -0
- package/dist/catalog-db/endpoints/get_properties_attachments.json +76 -0
- package/dist/catalog-db/endpoints/get_properties_marketing_photos.json +76 -0
- package/dist/catalog-db/endpoints/get_properties_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_properties_photos.json +76 -0
- package/dist/catalog-db/endpoints/get_property_groups.json +84 -0
- package/dist/catalog-db/endpoints/get_recurring_charges.json +106 -0
- package/dist/catalog-db/endpoints/get_rental_applications.json +186 -0
- package/dist/catalog-db/endpoints/get_rental_applications_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_showings.json +99 -0
- package/dist/catalog-db/endpoints/get_tenant_ledgers.json +82 -0
- package/dist/catalog-db/endpoints/get_tenants.json +273 -0
- package/dist/catalog-db/endpoints/get_tenants_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_unit_types.json +168 -0
- package/dist/catalog-db/endpoints/get_units.json +359 -0
- package/dist/catalog-db/endpoints/get_units_attachments.json +76 -0
- package/dist/catalog-db/endpoints/get_units_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_units_photos.json +70 -0
- package/dist/catalog-db/endpoints/get_users.json +88 -0
- package/dist/catalog-db/endpoints/get_vendors.json +317 -0
- package/dist/catalog-db/endpoints/get_vendors_notes.json +71 -0
- package/dist/catalog-db/endpoints/get_work_orders.json +322 -0
- package/dist/catalog-db/endpoints/get_work_orders_attachments.json +83 -0
- package/dist/catalog-db/endpoints/patch_bills_billid.json +156 -0
- package/dist/catalog-db/endpoints/patch_ca_violations_violationid.json +71 -0
- package/dist/catalog-db/endpoints/patch_inspections_inspectionid.json +77 -0
- package/dist/catalog-db/endpoints/patch_inventories_id.json +113 -0
- package/dist/catalog-db/endpoints/patch_inventory_locations_id.json +52 -0
- package/dist/catalog-db/endpoints/patch_journal_entries_journalentryid.json +90 -0
- package/dist/catalog-db/endpoints/patch_leads_guestcardid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_leads_leadid.json +162 -0
- package/dist/catalog-db/endpoints/patch_leases_leaseid.json +52 -0
- package/dist/catalog-db/endpoints/patch_owner_groups_id.json +72 -0
- package/dist/catalog-db/endpoints/patch_owners_ownerid.json +344 -0
- package/dist/catalog-db/endpoints/patch_owners_ownerid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_portfolios_portfolioid.json +46 -0
- package/dist/catalog-db/endpoints/patch_properties_propertyid.json +333 -0
- package/dist/catalog-db/endpoints/patch_properties_propertyid_marketing_photos_marketingphotoid.json +46 -0
- package/dist/catalog-db/endpoints/patch_properties_propertyid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_properties_propertyid_photos_photoid.json +46 -0
- package/dist/catalog-db/endpoints/patch_property_groups_propertygroupid.json +40 -0
- package/dist/catalog-db/endpoints/patch_recurring_charges_recurringchargeid.json +89 -0
- package/dist/catalog-db/endpoints/patch_rental_applications_rentalapplicationid.json +47 -0
- package/dist/catalog-db/endpoints/patch_rental_applications_rentalapplicationid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_showings_showingid.json +69 -0
- package/dist/catalog-db/endpoints/patch_tenants_bulk.json +131 -0
- package/dist/catalog-db/endpoints/patch_tenants_tenantid.json +48 -0
- package/dist/catalog-db/endpoints/patch_tenants_tenantid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_unit_types_unittypeid.json +126 -0
- package/dist/catalog-db/endpoints/patch_units_unitid.json +296 -0
- package/dist/catalog-db/endpoints/patch_units_unitid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_units_unitid_photos_photoid.json +46 -0
- package/dist/catalog-db/endpoints/patch_vendors_vendorid.json +263 -0
- package/dist/catalog-db/endpoints/patch_vendors_vendorid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/patch_work_orders_workorderid.json +273 -0
- package/dist/catalog-db/endpoints/patch_work_orders_workorderid_notes_id.json +46 -0
- package/dist/catalog-db/endpoints/post_bank_accounts_bulk.json +167 -0
- package/dist/catalog-db/endpoints/post_bills.json +160 -0
- package/dist/catalog-db/endpoints/post_bills_billid_attachments.json +40 -0
- package/dist/catalog-db/endpoints/post_bills_bulk.json +185 -0
- package/dist/catalog-db/endpoints/post_bills_bulk_async.json +192 -0
- package/dist/catalog-db/endpoints/post_ca_violations.json +63 -0
- package/dist/catalog-db/endpoints/post_ca_violations_violationid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_charges.json +56 -0
- package/dist/catalog-db/endpoints/post_charges_bulk.json +70 -0
- package/dist/catalog-db/endpoints/post_charges_bulk_async.json +70 -0
- package/dist/catalog-db/endpoints/post_charges_chargeid_attachments.json +40 -0
- package/dist/catalog-db/endpoints/post_corporate_gl_accounts_bulk.json +71 -0
- package/dist/catalog-db/endpoints/post_gl_accounts_bulk.json +77 -0
- package/dist/catalog-db/endpoints/post_inspections.json +70 -0
- package/dist/catalog-db/endpoints/post_inspections_bulk.json +76 -0
- package/dist/catalog-db/endpoints/post_inspections_inspectionid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_inventories.json +105 -0
- package/dist/catalog-db/endpoints/post_inventories_bulk.json +119 -0
- package/dist/catalog-db/endpoints/post_inventory_locations.json +44 -0
- package/dist/catalog-db/endpoints/post_inventory_locations_bulk.json +58 -0
- package/dist/catalog-db/endpoints/post_journal_entries.json +101 -0
- package/dist/catalog-db/endpoints/post_journal_entries_bulk.json +115 -0
- package/dist/catalog-db/endpoints/post_journal_entries_journalentryid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_late_fee_policies_bulk.json +115 -0
- package/dist/catalog-db/endpoints/post_leads.json +204 -0
- package/dist/catalog-db/endpoints/post_leads_guestcardid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_occupancies_occupancyid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_owner_groups.json +96 -0
- package/dist/catalog-db/endpoints/post_owners.json +330 -0
- package/dist/catalog-db/endpoints/post_owners_bulk.json +203 -0
- package/dist/catalog-db/endpoints/post_owners_ownerid_attachments.json +40 -0
- package/dist/catalog-db/endpoints/post_owners_ownerid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_properties_bulk.json +245 -0
- package/dist/catalog-db/endpoints/post_properties_propertyid_attachments.json +40 -0
- package/dist/catalog-db/endpoints/post_properties_propertyid_marketing_photos.json +52 -0
- package/dist/catalog-db/endpoints/post_properties_propertyid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_properties_propertyid_photos.json +40 -0
- package/dist/catalog-db/endpoints/post_recurring_charges.json +81 -0
- package/dist/catalog-db/endpoints/post_recurring_charges_bulk.json +95 -0
- package/dist/catalog-db/endpoints/post_rental_applications_rentalapplicationid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_rental_applications_rentalapplicationid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_showings.json +62 -0
- package/dist/catalog-db/endpoints/post_tenants_bulk.json +161 -0
- package/dist/catalog-db/endpoints/post_tenants_tenantid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_unit_types_bulk.json +138 -0
- package/dist/catalog-db/endpoints/post_units_bulk.json +236 -0
- package/dist/catalog-db/endpoints/post_units_unitid_attachments.json +40 -0
- package/dist/catalog-db/endpoints/post_units_unitid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_units_unitid_photos.json +40 -0
- package/dist/catalog-db/endpoints/post_vendors.json +279 -0
- package/dist/catalog-db/endpoints/post_vendors_bulk.json +279 -0
- package/dist/catalog-db/endpoints/post_vendors_vendorid_notes.json +40 -0
- package/dist/catalog-db/endpoints/post_work_orders.json +251 -0
- package/dist/catalog-db/endpoints/post_work_orders_workorderid_attachments.json +41 -0
- package/dist/catalog-db/endpoints/post_work_orders_workorderid_notes.json +40 -0
- package/dist/catalog-db/endpoints/put_leases_renewal_pricings_bulk.json +58 -0
- package/dist/catalog-db/endpoints/put_units_pricing_matrices_bulk.json +58 -0
- package/dist/catalog-db/endpoints/put_units_pricing_matrices_bulk_async.json +46 -0
- package/dist/catalog-db/endpoints/put_units_unitid_pricing_matrices.json +66 -0
- package/dist/catalog-db/index.json +1894 -0
- package/dist/clients/appfolio-client.js +131 -0
- package/dist/clients/appfolio-db-client.js +186 -0
- package/dist/helpers/register-db-tools.js +27 -0
- package/dist/helpers/register-report-tools.js +27 -0
- package/dist/index.js +10 -0
- package/dist/server/appfolio-mcp-server.js +18 -0
- package/dist/tools/call-endpoint.js +109 -0
- package/dist/tools/describe-endpoint.js +63 -0
- package/dist/tools/describe-report.js +55 -0
- package/dist/tools/list-endpoints.js +90 -0
- package/dist/tools/list-reports.js +58 -0
- package/dist/tools/run-report.js +102 -0
- package/package.json +42 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { formatError } from "@nightsquawktech/mcp-core/catalog";
|
|
2
|
+
const RATE_LIMIT_MAX = 7;
|
|
3
|
+
const RATE_LIMIT_WINDOW_MS = 15000;
|
|
4
|
+
export class AppfolioClient {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.vhost = null;
|
|
7
|
+
this.clientId = null;
|
|
8
|
+
this.clientSecret = null;
|
|
9
|
+
this.initialized = false;
|
|
10
|
+
this.requestTimestamps = [];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Lazily reads env vars on first API call, so import-time errors
|
|
14
|
+
* don't crash the process before the MCP server can report them.
|
|
15
|
+
*/
|
|
16
|
+
ensureInitialized() {
|
|
17
|
+
if (this.initialized)
|
|
18
|
+
return;
|
|
19
|
+
const missing = [];
|
|
20
|
+
const vhost = process.env.APPFOLIO_VHOST;
|
|
21
|
+
const clientId = process.env.APPFOLIO_CLIENT_ID;
|
|
22
|
+
const clientSecret = process.env.APPFOLIO_CLIENT_SECRET;
|
|
23
|
+
if (!vhost)
|
|
24
|
+
missing.push("APPFOLIO_VHOST");
|
|
25
|
+
if (!clientId)
|
|
26
|
+
missing.push("APPFOLIO_CLIENT_ID");
|
|
27
|
+
if (!clientSecret)
|
|
28
|
+
missing.push("APPFOLIO_CLIENT_SECRET");
|
|
29
|
+
if (missing.length > 0) {
|
|
30
|
+
throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
this.vhost = vhost;
|
|
33
|
+
this.clientId = clientId;
|
|
34
|
+
this.clientSecret = clientSecret;
|
|
35
|
+
this.initialized = true;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Waits if necessary to respect the 7-requests-per-15-seconds rate limit.
|
|
39
|
+
* Pagination URL fetches should NOT call this — they are exempt from rate limits.
|
|
40
|
+
*/
|
|
41
|
+
async waitForRateLimit() {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
// Remove timestamps older than the window
|
|
44
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
45
|
+
if (this.requestTimestamps.length >= RATE_LIMIT_MAX) {
|
|
46
|
+
const oldest = this.requestTimestamps[0];
|
|
47
|
+
const waitMs = RATE_LIMIT_WINDOW_MS - (now - oldest) + 50; // +50ms buffer
|
|
48
|
+
if (waitMs > 0) {
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
50
|
+
}
|
|
51
|
+
// Clean up again after waiting
|
|
52
|
+
const afterWait = Date.now();
|
|
53
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => afterWait - t < RATE_LIMIT_WINDOW_MS);
|
|
54
|
+
}
|
|
55
|
+
this.requestTimestamps.push(Date.now());
|
|
56
|
+
}
|
|
57
|
+
getAuthHeader() {
|
|
58
|
+
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64");
|
|
59
|
+
return `Basic ${credentials}`;
|
|
60
|
+
}
|
|
61
|
+
getBaseUrl(endpoint) {
|
|
62
|
+
return `https://${this.vhost}.appfolio.com/api/v2/reports/${endpoint}.json`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run an AppFolio report. Handles rate limiting and auto-pagination.
|
|
66
|
+
* Returns all results aggregated across pages.
|
|
67
|
+
*/
|
|
68
|
+
async runReport(endpoint, params) {
|
|
69
|
+
try {
|
|
70
|
+
this.ensureInitialized();
|
|
71
|
+
// Initial request (rate-limited)
|
|
72
|
+
await this.waitForRateLimit();
|
|
73
|
+
const url = this.getBaseUrl(endpoint);
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
Authorization: this.getAuthHeader(),
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify(params),
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const body = await response.text();
|
|
84
|
+
return {
|
|
85
|
+
result: null,
|
|
86
|
+
isError: true,
|
|
87
|
+
error: `HTTP ${response.status}: ${body}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
// If paginate_results=false, the API returns a plain array
|
|
92
|
+
if (Array.isArray(data)) {
|
|
93
|
+
return { result: data, isError: false, error: null };
|
|
94
|
+
}
|
|
95
|
+
// Paginated response: { results: [...], next_page_url?: "..." }
|
|
96
|
+
let allResults = data.results || [];
|
|
97
|
+
let nextPageUrl = data.next_page_url;
|
|
98
|
+
// Auto-follow pagination (these URLs bypass rate limits)
|
|
99
|
+
while (nextPageUrl) {
|
|
100
|
+
const pageResponse = await fetch(nextPageUrl, {
|
|
101
|
+
method: "GET",
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: this.getAuthHeader(),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
if (!pageResponse.ok) {
|
|
107
|
+
const body = await pageResponse.text();
|
|
108
|
+
return {
|
|
109
|
+
result: null,
|
|
110
|
+
isError: true,
|
|
111
|
+
error: `Pagination HTTP ${pageResponse.status}: ${body}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const pageData = await pageResponse.json();
|
|
115
|
+
if (Array.isArray(pageData)) {
|
|
116
|
+
allResults = allResults.concat(pageData);
|
|
117
|
+
nextPageUrl = undefined;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
allResults = allResults.concat(pageData.results || []);
|
|
121
|
+
nextPageUrl = pageData.next_page_url;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { result: allResults, isError: false, error: null };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return { result: null, isError: true, error: formatError(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const appfolioClient = new AppfolioClient();
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { formatError } from "@nightsquawktech/mcp-core/catalog";
|
|
2
|
+
const BASE_URL = "https://api.appfolio.com/api/v0";
|
|
3
|
+
// Documented global rate limits per credential set: 8/sec, 256/min, 4096/hour.
|
|
4
|
+
const RATE_WINDOWS = [
|
|
5
|
+
{ max: 8, ms: 1000 },
|
|
6
|
+
{ max: 256, ms: 60000 },
|
|
7
|
+
{ max: 4096, ms: 3600000 },
|
|
8
|
+
];
|
|
9
|
+
const MAX_429_RETRIES = 4;
|
|
10
|
+
export class AppfolioDbClient {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.clientId = null;
|
|
13
|
+
this.clientSecret = null;
|
|
14
|
+
this.developerId = null;
|
|
15
|
+
this.initialized = false;
|
|
16
|
+
this.requestTimestamps = [];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Lazily reads env vars on first API call, so import-time errors don't crash
|
|
20
|
+
* the process before the MCP server can report them.
|
|
21
|
+
*/
|
|
22
|
+
ensureInitialized() {
|
|
23
|
+
if (this.initialized)
|
|
24
|
+
return;
|
|
25
|
+
const missing = [];
|
|
26
|
+
const clientId = process.env.APPFOLIO_DB_CLIENT_ID;
|
|
27
|
+
const clientSecret = process.env.APPFOLIO_DB_CLIENT_SECRET;
|
|
28
|
+
const developerId = process.env.APPFOLIO_DB_DEVELOPER_ID;
|
|
29
|
+
if (!clientId)
|
|
30
|
+
missing.push("APPFOLIO_DB_CLIENT_ID");
|
|
31
|
+
if (!clientSecret)
|
|
32
|
+
missing.push("APPFOLIO_DB_CLIENT_SECRET");
|
|
33
|
+
if (!developerId)
|
|
34
|
+
missing.push("APPFOLIO_DB_DEVELOPER_ID");
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
this.clientId = clientId;
|
|
39
|
+
this.clientSecret = clientSecret;
|
|
40
|
+
this.developerId = developerId;
|
|
41
|
+
this.initialized = true;
|
|
42
|
+
}
|
|
43
|
+
/** Waits if necessary to respect all three documented rate-limit windows. */
|
|
44
|
+
async waitForRateLimit() {
|
|
45
|
+
// Prune timestamps older than the longest window.
|
|
46
|
+
const longest = RATE_WINDOWS[RATE_WINDOWS.length - 1].ms;
|
|
47
|
+
let now = Date.now();
|
|
48
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => now - t < longest);
|
|
49
|
+
// For each window, if we're at the cap, wait until the oldest in-window request ages out.
|
|
50
|
+
// Loop because waiting for one window may still leave another saturated.
|
|
51
|
+
for (let guard = 0; guard < RATE_WINDOWS.length * 2; guard++) {
|
|
52
|
+
now = Date.now();
|
|
53
|
+
let waitMs = 0;
|
|
54
|
+
for (const w of RATE_WINDOWS) {
|
|
55
|
+
const inWindow = this.requestTimestamps.filter((t) => now - t < w.ms);
|
|
56
|
+
if (inWindow.length >= w.max) {
|
|
57
|
+
const oldest = inWindow[0];
|
|
58
|
+
waitMs = Math.max(waitMs, w.ms - (now - oldest) + 50);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (waitMs <= 0)
|
|
62
|
+
break;
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
64
|
+
const after = Date.now();
|
|
65
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => after - t < longest);
|
|
66
|
+
}
|
|
67
|
+
this.requestTimestamps.push(Date.now());
|
|
68
|
+
}
|
|
69
|
+
getHeaders(hasBody) {
|
|
70
|
+
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64");
|
|
71
|
+
const headers = {
|
|
72
|
+
Authorization: `Basic ${credentials}`,
|
|
73
|
+
"X-AppFolio-Developer-ID": this.developerId,
|
|
74
|
+
};
|
|
75
|
+
if (hasBody)
|
|
76
|
+
headers["Content-Type"] = "application/json";
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
|
79
|
+
/** Substitute {param} placeholders in the path; URL-encode each value. */
|
|
80
|
+
buildPath(path, pathParams) {
|
|
81
|
+
return path.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
82
|
+
const v = pathParams?.[key];
|
|
83
|
+
if (v === undefined || v === null || v === "") {
|
|
84
|
+
throw new Error(`Missing path parameter "${key}" for "${path}"`);
|
|
85
|
+
}
|
|
86
|
+
return encodeURIComponent(String(v));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
buildQuery(opts) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (opts.filters) {
|
|
92
|
+
for (const [k, v] of Object.entries(opts.filters)) {
|
|
93
|
+
if (v === undefined || v === null)
|
|
94
|
+
continue;
|
|
95
|
+
// Allow callers to pass either "Id" or "filters[Id]".
|
|
96
|
+
const key = /^filters\[/.test(k) ? k : `filters[${k}]`;
|
|
97
|
+
const val = Array.isArray(v) ? v.join(",") : String(v);
|
|
98
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (opts.query) {
|
|
102
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
103
|
+
if (v === undefined || v === null)
|
|
104
|
+
continue;
|
|
105
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (opts.page?.number !== undefined)
|
|
109
|
+
parts.push(`page[number]=${encodeURIComponent(String(opts.page.number))}`);
|
|
110
|
+
if (opts.page?.size !== undefined)
|
|
111
|
+
parts.push(`page[size]=${encodeURIComponent(String(opts.page.size))}`);
|
|
112
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
113
|
+
}
|
|
114
|
+
/** One fetch with rate limiting + Retry-After-aware 429 retries. */
|
|
115
|
+
async fetchWithRetry(url, init) {
|
|
116
|
+
for (let attempt = 0;; attempt++) {
|
|
117
|
+
await this.waitForRateLimit();
|
|
118
|
+
const response = await fetch(url, init);
|
|
119
|
+
if (response.status === 429 && attempt < MAX_429_RETRIES) {
|
|
120
|
+
const retryAfter = parseInt(response.headers.get("Retry-After") || "1", 10);
|
|
121
|
+
const waitMs = (Number.isFinite(retryAfter) ? retryAfter : 1) * 1000 + 100;
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Execute an AppFolio Database API request. Handles auth, rate limiting,
|
|
130
|
+
* 429 Retry-After, and (for GET) auto-pagination via next_page_path.
|
|
131
|
+
*/
|
|
132
|
+
async request(method, path, opts = {}) {
|
|
133
|
+
try {
|
|
134
|
+
this.ensureInitialized();
|
|
135
|
+
const resolvedPath = this.buildPath(path, opts.pathParams);
|
|
136
|
+
const hasBody = opts.body !== undefined && method !== "GET";
|
|
137
|
+
const url = `${BASE_URL}${resolvedPath}${this.buildQuery(opts)}`;
|
|
138
|
+
const response = await this.fetchWithRetry(url, {
|
|
139
|
+
method,
|
|
140
|
+
headers: this.getHeaders(hasBody),
|
|
141
|
+
body: hasBody ? JSON.stringify(opts.body) : undefined,
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const errBody = await response.text();
|
|
145
|
+
return { result: null, isError: true, error: `HTTP ${response.status}: ${errBody}` };
|
|
146
|
+
}
|
|
147
|
+
// DELETE / 204 responses may have no body.
|
|
148
|
+
const text = await response.text();
|
|
149
|
+
const data = text ? JSON.parse(text) : null;
|
|
150
|
+
// Non-paginated (writes, single-record GETs, or pagination disabled): return as-is.
|
|
151
|
+
const paginate = opts.paginate !== false;
|
|
152
|
+
if (method !== "GET" ||
|
|
153
|
+
!paginate ||
|
|
154
|
+
!data ||
|
|
155
|
+
typeof data !== "object" ||
|
|
156
|
+
!("data" in data)) {
|
|
157
|
+
return { result: data, isError: false, error: null };
|
|
158
|
+
}
|
|
159
|
+
// Paginated GET: aggregate `data` arrays across next_page_path links.
|
|
160
|
+
let allData = Array.isArray(data.data) ? data.data : [];
|
|
161
|
+
let nextPath = data.next_page_path || undefined;
|
|
162
|
+
while (nextPath) {
|
|
163
|
+
const nextUrl = nextPath.startsWith("http")
|
|
164
|
+
? nextPath
|
|
165
|
+
: `https://api.appfolio.com${nextPath}`;
|
|
166
|
+
const pageResp = await this.fetchWithRetry(nextUrl, {
|
|
167
|
+
method: "GET",
|
|
168
|
+
headers: this.getHeaders(false),
|
|
169
|
+
});
|
|
170
|
+
if (!pageResp.ok) {
|
|
171
|
+
const errBody = await pageResp.text();
|
|
172
|
+
return { result: null, isError: true, error: `Pagination HTTP ${pageResp.status}: ${errBody}` };
|
|
173
|
+
}
|
|
174
|
+
const pageText = await pageResp.text();
|
|
175
|
+
const pageData = pageText ? JSON.parse(pageText) : {};
|
|
176
|
+
allData = allData.concat(Array.isArray(pageData.data) ? pageData.data : []);
|
|
177
|
+
nextPath = pageData.next_page_path || undefined;
|
|
178
|
+
}
|
|
179
|
+
return { result: { data: allData }, isError: false, error: null };
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return { result: null, isError: true, error: formatError(error) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export const appfolioDbClient = new AppfolioDbClient();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { RegisterTool } from "@nightsquawktech/mcp-core/catalog";
|
|
2
|
+
import { listEndpointsTool } from "../tools/list-endpoints.js";
|
|
3
|
+
import { describeEndpointTool } from "../tools/describe-endpoint.js";
|
|
4
|
+
import { callEndpointTool } from "../tools/call-endpoint.js";
|
|
5
|
+
import { loadDbIndex } from "../catalog-db/endpoint-spec.js";
|
|
6
|
+
/**
|
|
7
|
+
* Registers the consolidated AppFolio Database API (v0) tools with the MCP server.
|
|
8
|
+
*
|
|
9
|
+
* Like the Reporting API tools, we expose three catalog-backed tools instead of
|
|
10
|
+
* one tool per endpoint (~156 tools = heavy context cost and poor tool selection):
|
|
11
|
+
* - list_endpoints discover endpoints by category / resource / method
|
|
12
|
+
* - describe_endpoint full parameter + body spec for one endpoint
|
|
13
|
+
* - call_endpoint validate against the catalog spec, then execute
|
|
14
|
+
*
|
|
15
|
+
* Boot-time sanity check: the catalog index must load and be non-empty, so a
|
|
16
|
+
* missing/uncopied catalog fails fast at startup rather than on first tool call.
|
|
17
|
+
*/
|
|
18
|
+
export function registerAllDbTools(server) {
|
|
19
|
+
const index = loadDbIndex();
|
|
20
|
+
if (!index.endpoints || index.endpoints.length === 0) {
|
|
21
|
+
throw new Error("Database API catalog is empty or missing — expected src/catalog-db/index.json with endpoints. " +
|
|
22
|
+
"Did the build copy the catalog into dist/? Run `node scripts/assemble-catalog-db.mjs` to regenerate it.");
|
|
23
|
+
}
|
|
24
|
+
RegisterTool(server, listEndpointsTool);
|
|
25
|
+
RegisterTool(server, describeEndpointTool);
|
|
26
|
+
RegisterTool(server, callEndpointTool);
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { RegisterTool } from "@nightsquawktech/mcp-core/catalog";
|
|
2
|
+
import { listReportsTool } from "../tools/list-reports.js";
|
|
3
|
+
import { describeReportTool } from "../tools/describe-report.js";
|
|
4
|
+
import { runReportTool } from "../tools/run-report.js";
|
|
5
|
+
import { loadIndex } from "../catalog/report-spec.js";
|
|
6
|
+
/**
|
|
7
|
+
* Registers the consolidated AppFolio report tools with the MCP server.
|
|
8
|
+
*
|
|
9
|
+
* Instead of one tool per endpoint (~137 tools = heavy context cost and poor
|
|
10
|
+
* tool selection), we expose three tools backed by the report catalog:
|
|
11
|
+
* - list_reports discover reports by category
|
|
12
|
+
* - describe_report full parameter spec for one report
|
|
13
|
+
* - run_report validate against the catalog spec, then execute
|
|
14
|
+
*
|
|
15
|
+
* Boot-time sanity check: the catalog index must load and be non-empty, so a
|
|
16
|
+
* missing/uncopied catalog fails fast at startup rather than on first tool call.
|
|
17
|
+
*/
|
|
18
|
+
export function registerAllReportTools(server) {
|
|
19
|
+
const index = loadIndex();
|
|
20
|
+
if (!index.reports || index.reports.length === 0) {
|
|
21
|
+
throw new Error("Report catalog is empty or missing — expected src/catalog/index.json with reports. " +
|
|
22
|
+
"Did the build copy the catalog into dist/?");
|
|
23
|
+
}
|
|
24
|
+
RegisterTool(server, listReportsTool);
|
|
25
|
+
RegisterTool(server, describeReportTool);
|
|
26
|
+
RegisterTool(server, runReportTool);
|
|
27
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { AppfolioMCPServer } from "./server/appfolio-mcp-server.js";
|
|
4
|
+
import { registerAllReportTools } from "./helpers/register-report-tools.js";
|
|
5
|
+
import { registerAllDbTools } from "./helpers/register-db-tools.js";
|
|
6
|
+
const server = AppfolioMCPServer.GetServer();
|
|
7
|
+
registerAllReportTools(server);
|
|
8
|
+
registerAllDbTools(server);
|
|
9
|
+
const transport = new StdioServerTransport();
|
|
10
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export class AppfolioMCPServer {
|
|
3
|
+
constructor() { }
|
|
4
|
+
static GetServer() {
|
|
5
|
+
if (AppfolioMCPServer.instance === null) {
|
|
6
|
+
AppfolioMCPServer.instance = new McpServer({
|
|
7
|
+
name: "AppFolio MCP Server",
|
|
8
|
+
version: "0.0.1",
|
|
9
|
+
}, {
|
|
10
|
+
capabilities: {
|
|
11
|
+
tools: {},
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return AppfolioMCPServer.instance;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
AppfolioMCPServer.instance = null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loadEndpointSpec, loadDbIndex } from "../catalog-db/endpoint-spec.js";
|
|
3
|
+
import { appfolioDbClient } from "../clients/appfolio-db-client.js";
|
|
4
|
+
const schema = z.object({
|
|
5
|
+
operation_id: z
|
|
6
|
+
.string()
|
|
7
|
+
.describe("The endpoint operationId to call, e.g. 'get_tenants' or 'post_bills'. See list_endpoints / describe_endpoint."),
|
|
8
|
+
path_params: z
|
|
9
|
+
.record(z.any())
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Values for {param} placeholders in the path, e.g. { billId: '1abc...' } for /bills/{billId}."),
|
|
12
|
+
filters: z
|
|
13
|
+
.record(z.any())
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("GET filter params keyed by filter name (the 'filters[...]' wrapper is added automatically), " +
|
|
16
|
+
"e.g. { LastUpdatedAtFrom: '2020-01-01T00:00:00Z', Id: 'uuid,uuid' }. Arrays are comma-joined."),
|
|
17
|
+
query: z
|
|
18
|
+
.record(z.any())
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Additional raw query params (rare; most filtering goes through `filters`)."),
|
|
21
|
+
body: z
|
|
22
|
+
.any()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("JSON request body for write operations (POST/PATCH/PUT). See describe_endpoint for the schema."),
|
|
25
|
+
page: z
|
|
26
|
+
.object({ number: z.number().optional(), size: z.number().optional() })
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Pagination for GET list endpoints: { number, size }. Default size is 1000."),
|
|
29
|
+
paginate_results: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("GET only. Default true: follow next_page_path and aggregate all rows. Set false for a single page."),
|
|
33
|
+
});
|
|
34
|
+
function errorText(text) {
|
|
35
|
+
return { content: [{ type: "text", text }] };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Executes an AppFolio Database API request after validating against the catalog
|
|
39
|
+
* spec: the endpoint must exist and all required path params must be present.
|
|
40
|
+
* Query filters are advisory (the reference rarely enumerates them all), so
|
|
41
|
+
* unknown filters are NOT rejected. Read and write operations both execute
|
|
42
|
+
* (no write gate, per configuration).
|
|
43
|
+
*/
|
|
44
|
+
export const callEndpointTool = {
|
|
45
|
+
name: "call_endpoint",
|
|
46
|
+
description: "Call an AppFolio Database API endpoint and return its JSON response. Validates the " +
|
|
47
|
+
"operationId and required path params against the catalog before calling AppFolio, then " +
|
|
48
|
+
"executes (GET reads are auto-paginated; POST/PATCH/PUT/DELETE writes execute live against " +
|
|
49
|
+
"your AppFolio database). Use describe_endpoint first to learn the exact params and body.",
|
|
50
|
+
schema,
|
|
51
|
+
handler: async ({ params }) => {
|
|
52
|
+
const operationId = params?.operation_id;
|
|
53
|
+
const spec = loadEndpointSpec(operationId);
|
|
54
|
+
if (!spec) {
|
|
55
|
+
const known = loadDbIndex().endpoints.map((e) => e.operationId);
|
|
56
|
+
const suggestions = known
|
|
57
|
+
.filter((id) => operationId && (id.includes(operationId) || operationId.includes(id)))
|
|
58
|
+
.slice(0, 5);
|
|
59
|
+
return errorText(`Unknown endpoint "${operationId}". ` +
|
|
60
|
+
(suggestions.length ? `Did you mean: ${suggestions.join(", ")}? ` : "") +
|
|
61
|
+
`Call list_endpoints to see all ${known.length} endpoints.`);
|
|
62
|
+
}
|
|
63
|
+
// Enforce required path params (hard — the URL can't be built without them).
|
|
64
|
+
const required = (spec.pathParams ?? []).filter((p) => p.required !== false);
|
|
65
|
+
const provided = params?.path_params ?? {};
|
|
66
|
+
const missing = required.filter((p) => {
|
|
67
|
+
const v = provided[p.name];
|
|
68
|
+
return v === undefined || v === null || (typeof v === "string" && v.trim() === "");
|
|
69
|
+
});
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
return errorText(`Missing required path parameter(s) for "${spec.operationId}" (${spec.method} ${spec.path}): ` +
|
|
72
|
+
`${missing.map((p) => p.name).join(", ")}. Call describe_endpoint("${spec.operationId}") for details.`);
|
|
73
|
+
}
|
|
74
|
+
// Guard: a write body was supplied to a GET (likely a mistake).
|
|
75
|
+
if (spec.method === "GET" && params?.body !== undefined) {
|
|
76
|
+
return errorText(`"${spec.operationId}" is a GET endpoint and does not accept a body. ` +
|
|
77
|
+
`Use "filters" for query criteria.`);
|
|
78
|
+
}
|
|
79
|
+
const response = await appfolioDbClient.request(spec.method, spec.path, {
|
|
80
|
+
pathParams: provided,
|
|
81
|
+
filters: params?.filters,
|
|
82
|
+
query: params?.query,
|
|
83
|
+
body: params?.body,
|
|
84
|
+
page: params?.page,
|
|
85
|
+
paginate: params?.paginate_results,
|
|
86
|
+
});
|
|
87
|
+
if (response.isError) {
|
|
88
|
+
return errorText(`Error calling ${spec.operationId} (${spec.method} ${spec.path}): ${response.error}`);
|
|
89
|
+
}
|
|
90
|
+
const result = response.result;
|
|
91
|
+
const rowCount = result && typeof result === "object" && Array.isArray(result.data)
|
|
92
|
+
? result.data.length
|
|
93
|
+
: undefined;
|
|
94
|
+
return {
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: "text",
|
|
98
|
+
text: JSON.stringify({
|
|
99
|
+
operation_id: spec.operationId,
|
|
100
|
+
method: spec.method,
|
|
101
|
+
path: spec.path,
|
|
102
|
+
...(rowCount !== undefined ? { rowCount } : {}),
|
|
103
|
+
result,
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loadEndpointSpec, loadDbIndex } from "../catalog-db/endpoint-spec.js";
|
|
3
|
+
const schema = z.object({
|
|
4
|
+
operation_id: z
|
|
5
|
+
.string()
|
|
6
|
+
.describe("The endpoint operationId, e.g. 'get_tenants' or 'post_bills'. See list_endpoints."),
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Returns the full spec for one Database API endpoint: HTTP method, path, path
|
|
10
|
+
* params, query filters (for GET), the request body schema with nested
|
|
11
|
+
* attributes (for writes), response fields, and a request sample. The model
|
|
12
|
+
* calls this just before call_endpoint so it sends the exact parameters expected.
|
|
13
|
+
*/
|
|
14
|
+
export const describeEndpointTool = {
|
|
15
|
+
name: "describe_endpoint",
|
|
16
|
+
description: "Get the full specification for a single AppFolio Database API endpoint: HTTP method, " +
|
|
17
|
+
"path and path params, query filters (GET), request body schema with nested attributes " +
|
|
18
|
+
"(writes), response fields, and a request sample. Call this before call_endpoint to know " +
|
|
19
|
+
"exactly which parameters to pass.",
|
|
20
|
+
schema,
|
|
21
|
+
handler: async ({ params }) => {
|
|
22
|
+
const operationId = params?.operation_id;
|
|
23
|
+
const spec = loadEndpointSpec(operationId);
|
|
24
|
+
if (!spec) {
|
|
25
|
+
const known = loadDbIndex().endpoints.map((e) => e.operationId);
|
|
26
|
+
const suggestions = known
|
|
27
|
+
.filter((id) => operationId && (id.includes(operationId) || operationId.includes(id)))
|
|
28
|
+
.slice(0, 5);
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: `Unknown endpoint "${operationId}". ` +
|
|
34
|
+
(suggestions.length ? `Did you mean: ${suggestions.join(", ")}? ` : "") +
|
|
35
|
+
`Call list_endpoints to see all ${known.length} endpoints.`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const payload = {
|
|
41
|
+
operation_id: spec.operationId,
|
|
42
|
+
name: spec.name,
|
|
43
|
+
method: spec.method,
|
|
44
|
+
path: spec.path,
|
|
45
|
+
description: spec.description,
|
|
46
|
+
writeOperation: spec.writeOperation,
|
|
47
|
+
pagination: !!spec.pagination,
|
|
48
|
+
...(spec.deprecated ? { deprecated: true } : {}),
|
|
49
|
+
pathParams: spec.pathParams ?? [],
|
|
50
|
+
queryFilters: spec.queryFilters ?? [],
|
|
51
|
+
...(spec.requestBody ? { requestBody: spec.requestBody } : {}),
|
|
52
|
+
responseFields: spec.responseFields ?? [],
|
|
53
|
+
paginationParams: spec.pagination ? loadDbIndex().paginationParams : [],
|
|
54
|
+
requestSample: spec.requestSample,
|
|
55
|
+
note: "Query filters are advisory — AppFolio's reference often does not enumerate every " +
|
|
56
|
+
"filter, so call_endpoint does not reject unknown filters. Most GET list endpoints " +
|
|
57
|
+
"require at least a filters[Id] or filters[LastUpdatedAtFrom] value.",
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loadReportSpec, loadIndex, universalParameters } from "../catalog/report-spec.js";
|
|
3
|
+
const schema = z.object({
|
|
4
|
+
report_name: z
|
|
5
|
+
.string()
|
|
6
|
+
.describe("The report slug, e.g. 'balance_sheet' or 'rent_roll'. See list_reports."),
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Returns the full parameter spec for one report: every filter with its type,
|
|
10
|
+
* whether it's required, allowed enum values, defaults, nested object attributes,
|
|
11
|
+
* the response columns, and a request sample. The model calls this just before
|
|
12
|
+
* run_report so it sends the exact parameter keys this report expects.
|
|
13
|
+
*/
|
|
14
|
+
export const describeReportTool = {
|
|
15
|
+
name: "describe_report",
|
|
16
|
+
description: "Get the full parameter specification for a single AppFolio report: required and " +
|
|
17
|
+
"optional filters, types, enums, defaults, nested attributes, and response columns. " +
|
|
18
|
+
"Call this before run_report to know exactly which parameters to pass.",
|
|
19
|
+
schema,
|
|
20
|
+
handler: async ({ params }) => {
|
|
21
|
+
const reportName = params?.report_name;
|
|
22
|
+
const spec = loadReportSpec(reportName);
|
|
23
|
+
if (!spec) {
|
|
24
|
+
const known = loadIndex().reports.map((r) => r.slug);
|
|
25
|
+
const suggestions = known
|
|
26
|
+
.filter((s) => reportName && (s.includes(reportName) || reportName.includes(s)))
|
|
27
|
+
.slice(0, 5);
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: `Unknown report "${reportName}". ` +
|
|
33
|
+
(suggestions.length
|
|
34
|
+
? `Did you mean: ${suggestions.join(", ")}? `
|
|
35
|
+
: "") +
|
|
36
|
+
`Call list_reports to see all ${known.length} reports.`,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const payload = {
|
|
42
|
+
report_name: spec.slug,
|
|
43
|
+
name: spec.name,
|
|
44
|
+
description: spec.description,
|
|
45
|
+
requiredFilters: spec.requiredFilters,
|
|
46
|
+
parameters: spec.parameters,
|
|
47
|
+
universalParameters: universalParameters(),
|
|
48
|
+
responseColumns: spec.responseColumns,
|
|
49
|
+
requestSample: spec.requestSample,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|