[ 'label' => __('Get CRM Context', 'fluent-crm'), 'description' => __('Discovery. Returns identity, permissions, stats, top tags/lists, available triggers/actions, all enums, custom fields schema, default sender. Call once per session.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [ContextTools::class, 'getContext'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_view_dashboard') || PermissionManager::currentUserCan('fcrm_read_contacts'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/list-contacts' => [ 'label' => __('List Contacts', 'fluent-crm'), 'description' => __('List/filter contacts with tags + lists inline. `search` matches name/email/custom field values. Filter fields are strictly validated — see get-crm-context.enums for valid status values.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => ['type' => 'string', 'description' => 'Full-text across first_name, last_name, email, and custom field values.'], 'tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']], 'description' => 'Tag ids or slugs/titles. Mixed allowed.'], 'lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']], 'description' => 'List ids or slugs/titles. Mixed allowed.'], 'statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.contact_statuses.'], 'sms_statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.sms_statuses.'], 'contact_type' => ['type' => 'string', 'enum' => ['lead', 'customer']], 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or full ISO 8601. Site timezone.'], 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or full ISO 8601. Site timezone.'], 'sort_by' => ['type' => 'string', 'enum' => ['id', 'email', 'first_name', 'last_name', 'created_at', 'last_activity'], 'default' => 'id'], 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], 'page' => ['type' => 'integer', 'default' => 1], 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'], 'include_custom_fields' => ['type' => 'boolean', 'default' => false, 'description' => 'Inline each contact\'s custom field values (heavier).'], ], ], 'execute_callback' => [ContactTools::class, 'listContacts'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_contacts'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/get-contact' => [ 'label' => __('Get Contact', 'fluent-crm'), 'description' => __('Full contact profile. Provide contact_id OR email. Default include: notes, email_history, automations. Optional: activity, purchase_history, support_tickets, ai_summary, info_widgets.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'], 'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'], 'include' => [ 'type' => 'array', 'description' => 'Adds optional sections to the response. The default 3 are always included.', 'items' => ['type' => 'string', 'enum' => ['notes', 'email_history', 'automations', 'activity', 'purchase_history', 'support_tickets', 'ai_summary', 'info_widgets']], ], 'generate_ai_summary' => ['type' => 'boolean', 'default' => false, 'description' => 'When true and ai_summary is in include, force a fresh AI call (costs provider tokens).'], ], ], 'execute_callback' => [ContactTools::class, 'getContact'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_contacts'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/list-campaigns' => [ 'label' => __('List Campaigns', 'fluent-crm'), 'description' => __('List campaigns with stats inline. Excludes one-off email-to-contact records by default — flip include_one_offs for a unified "what was sent recently" view.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => ['type' => 'string', 'description' => 'Matches campaign title.'], 'statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.campaign_statuses.'], 'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'updated_at', 'scheduled_at'], 'default' => 'created_at'], 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], 'include_stats' => ['type' => 'boolean', 'default' => true, 'description' => 'When true, computes per-campaign stats inline (one extra query per row — turn off for cheap title scans).'], 'include_one_offs' => ['type' => 'boolean', 'default' => false, 'description' => 'Also include the per-recipient custom-email rows created by send-email-to-contact.'], 'page' => ['type' => 'integer', 'default' => 1], 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'], ], ], 'execute_callback' => [CampaignTools::class, 'listCampaigns'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_emails'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/get-campaign' => [ 'label' => __('Get Campaign', 'fluent-crm'), 'description' => __('Campaign details. Default include: stats. Optional: subjects (A/B), link_report, recipients_estimate.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'campaign_id' => ['type' => 'integer'], 'include' => [ 'type' => 'array', 'items' => ['type' => 'string', 'enum' => ['stats', 'subjects', 'link_report', 'recipients_estimate']], ], ], 'required' => ['campaign_id'], ], 'execute_callback' => [CampaignTools::class, 'getCampaign'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_emails'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/list-automations' => [ 'label' => __('List Automations', 'fluent-crm'), 'description' => __('List/filter automations (funnels) with subscriber counts inline.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => ['type' => 'string'], 'statuses' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['draft', 'published']]], 'sort_by' => ['type' => 'string', 'enum' => ['id', 'title', 'status', 'updated_at'], 'default' => 'id'], 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], 'page' => ['type' => 'integer', 'default' => 1], 'per_page' => ['type' => 'integer', 'default' => 15], ], ], 'execute_callback' => [FunnelTools::class, 'listAutomations'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_funnels'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/list-funnel-subscribers' => [ 'label' => __('List Funnel Subscribers', 'fluent-crm'), 'description' => __('List contacts enrolled in a funnel by status. Use to find candidates for update-contact-automation-status when you only know the funnel.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'funnel_id' => ['type' => 'integer'], 'statuses' => [ 'type' => 'array', 'items' => ['type' => 'string', 'enum' => ['active', 'waiting', 'completed', 'cancelled', 'skipped']], 'description' => 'Defaults to ["active"].', ], 'page' => ['type' => 'integer', 'default' => 1], 'per_page' => ['type' => 'integer', 'default' => 15], ], 'required' => ['funnel_id'], ], 'execute_callback' => [FunnelTools::class, 'listFunnelSubscribers'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_funnels'); }, 'annotations' => ['readonly' => true], ], 'fluent-crm/get-automation' => [ 'label' => __('Get Automation', 'fluent-crm'), 'description' => __('Funnel details with sequences and per-step report by default. Embedded email bodies in send_custom_email steps are stripped unless include_bodies=true (saves tokens).', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'funnel_id' => ['type' => 'integer'], 'include' => [ 'type' => 'array', 'description' => 'Defaults to ["sequences","report"]. Pass [] for metadata only.', 'items' => ['type' => 'string', 'enum' => ['sequences', 'report']], ], 'include_bodies' => ['type' => 'boolean', 'default' => false, 'description' => 'Return full email bodies inside send_custom_email step settings. Off by default — large funnels can blow agent context.'], ], 'required' => ['funnel_id'], ], 'execute_callback' => [FunnelTools::class, 'getAutomation'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_read_funnels'); }, 'annotations' => ['readonly' => true], ], // ----------------------------------------------------------------- // Phase 3 — write tools // ----------------------------------------------------------------- 'fluent-crm/upsert-contact' => [ 'label' => __('Create or Update Contact', 'fluent-crm'), 'description' => __('Create or update a contact by id or email. status changes fire native hooks. Source stamps "mcp" only on create — preserved on update. new_email renames in place.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email for lookup.'], 'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id for lookup. Required for create.'], 'new_email' => ['type' => 'string', 'description' => 'Renames an existing contact in place. Errors if another contact already uses this email.'], 'first_name' => ['type' => 'string'], 'last_name' => ['type' => 'string'], 'prefix' => ['type' => 'string'], 'phone' => ['type' => 'string'], 'status' => ['type' => 'string', 'description' => 'See get-crm-context.enums.contact_statuses.'], 'contact_type' => ['type' => 'string', 'enum' => ['lead', 'customer']], 'address' => ['type' => 'object', 'description' => 'Object: {line_1, line_2, city, state, postal_code, country (ISO-2)}. Empty fields are ignored.'], 'date_of_birth' => ['type' => 'string', 'description' => 'YYYY-MM-DD.'], 'timezone' => ['type' => 'string'], 'source' => ['type' => 'string', 'description' => 'Defaults to "mcp" on create. Omit on updates to preserve existing source.'], 'custom_fields' => ['type' => 'object', 'description' => 'Map of custom field slug → value. See get-crm-context.custom_fields_schema.'], 'add_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'remove_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'add_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'remove_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'auto_create_tags' => ['type' => 'boolean', 'default' => false, 'description' => 'Re-checks fcrm_manage_contact_cats. Off by default for safety.'], 'auto_create_lists' => ['type' => 'boolean', 'default' => false], 'double_optin' => ['type' => 'boolean', 'default' => false, 'description' => 'When status=pending, send opt-in email. No-op for other statuses.'], 'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge', 'description' => 'merge: update existing fields. skip: leave row untouched. error: return contact_exists.'], 'status_change_reason' => ['type' => 'string', 'description' => 'When provided AND status changes, auto-creates an audit note ("Status changed via MCP").'], ], ], 'execute_callback' => [ContactTools::class, 'upsertContact'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts'); }, ], 'fluent-crm/bulk-upsert-contacts' => [ 'label' => __('Bulk Create or Update Contacts', 'fluent-crm'), 'description' => __('Batch create/update up to 500 contacts. Returns per-row {created, updated, skipped, invalid}. auto_create defaults to true here (matches CSV-import expectations) — opposite of upsert-contact.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contacts' => [ 'type' => 'array', 'description' => 'Array of contact objects. Each object accepts the same fields as upsert-contact but no add_tags/remove_tags — pass `tags` and `lists` directly.', 'items' => ['type' => 'object'], ], 'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge'], 'double_optin' => ['type' => 'boolean', 'default' => false], 'auto_create_tags' => ['type' => 'boolean', 'default' => true, 'description' => 'Default true here (bulk-import context). Re-checks fcrm_manage_contact_cats.'], 'auto_create_lists' => ['type' => 'boolean', 'default' => true], ], 'required' => ['contacts'], ], 'execute_callback' => [ContactTools::class, 'bulkUpsertContacts'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts'); }, 'annotations' => ['bulk' => true], ], 'fluent-crm/delete-contact' => [ 'label' => __('Delete Contact', 'fluent-crm'), 'description' => __('Hard-delete a contact. Optional delete_emails wipes the email log too. Cannot be undone.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_id' => ['type' => 'integer'], 'email' => ['type' => 'string'], 'delete_emails' => ['type' => 'boolean', 'default' => true], ], ], 'execute_callback' => [ContactTools::class, 'deleteContact'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts_delete'); }, 'annotations' => ['destructive' => true], ], 'fluent-crm/apply-segments-to-contacts' => [ 'label' => __('Apply Tags/Lists Across Contacts', 'fluent-crm'), 'description' => __('Add/remove tags and lists across many contacts. Provide contact_ids OR filter, not both. Always dry_run first for filter-based applies. Response includes applied_contact_ids for precise reversal. Cap 5000.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Explicit ids. Use OR filter, not both.'], 'filter' => ['type' => 'object', 'description' => 'Universal filter — {tags, lists, statuses, contact_type, search, created_after, created_before}. See get-crm-context.guidelines.'], 'add_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'remove_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'add_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'remove_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]], 'auto_create_tags' => ['type' => 'boolean', 'default' => false, 'description' => 'Re-checks fcrm_manage_contact_cats. Suppressed during dry_run so previews never leave orphans behind.'], 'auto_create_lists' => ['type' => 'boolean', 'default' => false], 'dry_run' => ['type' => 'boolean', 'default' => false, 'description' => 'Preview matched count, batches_required, and tags/lists_would_create without applying. Bypasses the cap (you see real matched_contacts even if > 5000).'], ], ], 'execute_callback' => [ContactTools::class, 'applySegmentsToContacts'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts'); }, 'annotations' => ['bulk' => true], ], 'fluent-crm/manage-tag' => [ 'label' => __('Manage Tag', 'fluent-crm'), 'description' => __('Create, update, delete, or merge tags. delete + merge are destructive (re-pivot or detach subscribers).', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'action' => ['type' => 'string', 'enum' => ['create', 'update', 'delete', 'merge']], 'tag_id' => ['type' => 'integer', 'description' => 'Required for update/delete.'], 'title' => ['type' => 'string'], 'slug' => ['type' => 'string'], 'description' => ['type' => 'string'], 'force' => ['type' => 'boolean', 'default' => false, 'description' => 'delete only — allow deletion when subscribers are still attached.'], 'from_tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'merge only — source tags whose subscribers move to to_tag_id and which then get deleted.'], 'to_tag_id' => ['type' => 'integer', 'description' => 'merge only — destination tag.'], ], 'required' => ['action'], ], 'execute_callback' => [SegmentTools::class, 'manageTag'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contact_cats') || PermissionManager::currentUserCan('fcrm_manage_contact_cats_delete'); }, 'annotations' => ['destructive' => true], ], 'fluent-crm/manage-list' => [ 'label' => __('Manage List', 'fluent-crm'), 'description' => __('Create, update, delete, or merge lists. delete + merge are destructive (re-pivot or detach subscribers).', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'action' => ['type' => 'string', 'enum' => ['create', 'update', 'delete', 'merge']], 'list_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'slug' => ['type' => 'string'], 'description' => ['type' => 'string'], 'force' => ['type' => 'boolean', 'default' => false], 'from_list_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], 'to_list_id' => ['type' => 'integer'], ], 'required' => ['action'], ], 'execute_callback' => [SegmentTools::class, 'manageList'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contact_cats') || PermissionManager::currentUserCan('fcrm_manage_contact_cats_delete'); }, 'annotations' => ['destructive' => true], ], 'fluent-crm/delete-contact-note' => [ 'label' => __('Delete Contact Note', 'fluent-crm'), 'description' => __('Delete a single subscriber note by id. Find the note id via get-contact include=["notes"]. Other notes and email history are untouched.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'note_id' => ['type' => 'integer'], ], 'required' => ['note_id'], ], 'execute_callback' => [ContactTools::class, 'deleteContactNote'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts'); }, 'annotations' => ['destructive' => true], ], 'fluent-crm/add-contact-note' => [ 'label' => __('Add Contact Note', 'fluent-crm'), 'description' => __('Add a note to a contact. Provide contact_id OR email plus title + description. Types: note, call, email, meeting, quote. Description supports HTML.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'], 'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'], 'type' => ['type' => 'string', 'enum' => ['note', 'call', 'email', 'meeting', 'quote'], 'default' => 'note'], 'title' => ['type' => 'string', 'description' => 'Max 192 chars.'], 'description' => ['type' => 'string', 'description' => 'HTML or plain. SmartCodes resolve.'], 'created_at' => ['type' => 'string', 'description' => 'ISO 8601, defaults to now (site timezone).'], ], 'required' => ['title', 'description'], ], 'execute_callback' => [ContactTools::class, 'addContactNote'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_contacts'); }, ], 'fluent-crm/send-test-email' => [ 'label' => __('Send Test Email', 'fluent-crm'), 'description' => __('Render and send a test copy of an email — does not enroll the recipient, does not create a campaign record, does not log to email_history. Subject is prefixed with "TEST:".', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'to_email' => ['type' => 'string', 'description' => 'Where to send the test. Defaults to the current WP user\'s email.'], 'campaign_id' => ['type' => 'integer', 'description' => 'Send a test copy of this saved campaign\'s body / subject / settings.'], 'subject' => ['type' => 'string', 'description' => 'Override or supply a subject when not using campaign_id.'], 'body' => ['type' => 'string', 'description' => 'Override or supply a body when not using campaign_id.'], 'pre_header' => ['type' => 'string'], 'design_template' => [ 'type' => 'string', 'enum' => array_keys(ContextTools::allowedDesignTemplates()), ], 'against_contact_id' => ['type' => 'integer', 'description' => 'Resolve smartcodes against this contact. Defaults to a contact matching to_email, then any subscribed contact.'], 'against_contact_email' => ['type' => 'string'], ], ], 'execute_callback' => [EmailTools::class, 'sendTestEmail'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_emails'); }, ], 'fluent-crm/send-email-to-contact' => [ 'label' => __('Send Email to Contact', 'fluent-crm'), 'description' => __('Send a one-off email to a subscribed/transactional contact. Routes through normal queue + bounce + FluentSMTP. SmartCodes resolve. Persists a custom_email_campaign record (hidden from list-campaigns by default).', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'], 'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'], 'subject' => ['type' => 'string'], 'body' => ['type' => 'string', 'description' => 'HTML or plain. SmartCodes resolve.'], 'pre_header' => ['type' => 'string'], 'title' => ['type' => 'string', 'description' => 'Internal log title; defaults to "MCP one-off to {email}".'], 'design_template' => [ 'type' => 'string', 'enum' => array_keys(ContextTools::allowedDesignTemplates()), 'default' => 'classic', ], 'from_name' => ['type' => 'string', 'description' => 'Defaults to site sender (get-crm-context.default_sender.from_name).'], 'from_email' => ['type' => 'string', 'description' => 'Defaults to site sender. Must be a configured/verified address.'], 'reply_to_name' => ['type' => 'string'], 'reply_to_email' => ['type' => 'string'], 'is_transactional' => ['type' => 'string', 'enum' => ['yes', 'no'], 'default' => 'no', 'description' => 'When "yes", also auto-disables the global marketing footer for transactional-mail compliance.'], 'disable_footer' => ['type' => 'string', 'enum' => ['yes', 'no'], 'description' => 'Explicit override of the auto-derived footer behavior.'], 'click_tracker' => ['type' => 'string', 'enum' => ['yes', 'no', 'anonymous']], 'open_tracker' => ['type' => 'string', 'enum' => ['yes', 'no', 'anonymous']], 'utm' => ['type' => 'object', 'description' => 'Optional {status:0|1, source, medium, campaign, term, content}. status defaults to 0.'], 'settings' => ['type' => 'object', 'description' => 'Free-form passthrough merged into campaign.settings (template_config, footer_settings). Caller keys override our defaults.'], ], 'required' => ['subject', 'body'], ], 'execute_callback' => [EmailTools::class, 'sendEmailToContact'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_emails'); }, ], 'fluent-crm/upsert-campaign' => [ 'label' => __('Create or Update Campaign', 'fluent-crm'), 'description' => __('Create or update a draft campaign. Never sends — use change-campaign-status to schedule. recipients persists tags + lists ONLY (no statuses/contact_type — apply a temp tag first). Returns estimated_recipients + warnings inline.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'campaign_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'email_subject' => ['type' => 'string'], 'email_pre_header' => ['type' => 'string'], 'email_body' => ['type' => 'string'], 'design_template' => [ 'type' => 'string', 'enum' => array_keys(ContextTools::allowedDesignTemplates()), 'default' => 'classic', ], 'settings' => [ 'type' => 'object', 'description' => 'Merged into campaign.settings. Shape: {mailer_settings:{from_name,from_email,reply_to_name,reply_to_email,is_custom:yes|no}, is_transactional:yes|no, click_tracker:yes|no|anonymous, open_tracker:yes|no|anonymous, footer_settings:{disable_footer:yes|no}, template_config}.', ], 'recipients' => [ 'type' => 'object', 'description' => 'Recipient segment. Persists {tags:[id|slug|title], lists:[id|slug|title]} only. Pass other keys (statuses, contact_type, advanced_filters) and the call hard-errors with the temp-tag workaround.', ], 'exclude_recipients' => [ 'type' => 'object', 'description' => 'Same shape + restriction as recipients.', ], 'subjects' => [ 'type' => 'array', 'description' => 'A/B subjects. Each: {value: string [, key: string]}. Pass an array with 2+ items to enable A/B; the regular email_subject still acts as the primary line. Optional `key` is a stable identifier used internally — auto-generated if omitted.', 'items' => [ 'type' => 'object', 'properties' => [ 'value' => ['type' => 'string'], 'key' => ['type' => 'string'], ], ], ], 'label_ids' => ['type' => 'array', 'items' => ['type' => 'integer']], 'utm' => [ 'type' => 'object', 'description' => 'Optional. {status: 0|1 to toggle, source, medium, campaign, term, content}. status defaults to 0 (off).', ], 'if_exists' => [ 'type' => 'string', 'enum' => ['auto_suffix', 'error'], 'default' => 'auto_suffix', 'description' => 'On title conflict during create: auto_suffix (Title (2), Title (3)) or hard error.', ], ], ], 'execute_callback' => [CampaignTools::class, 'upsertCampaign'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_emails'); }, ], 'fluent-crm/change-campaign-status' => [ 'label' => __('Change Campaign Status', 'fluent-crm'), 'description' => __('State transition. schedule + delete are destructive. pause/resume only valid mid-send (working↔paused). unschedule reverts to draft and clears scheduled_at.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'campaign_id' => ['type' => 'integer'], 'action' => ['type' => 'string', 'enum' => ['schedule', 'unschedule', 'pause', 'resume', 'duplicate', 'delete']], 'scheduled_at' => ['type' => 'string', 'description' => 'Required when action=schedule and sending_type≠instant. Site timezone (see get-crm-context.site.timezone). Must be in the future.'], 'schedule_range' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Required when sending_type=range_schedule. [startISO, endISO].'], 'sending_type' => ['type' => 'string', 'enum' => ['instant', 'schedule', 'range_schedule'], 'description' => 'Defaults to "schedule" if scheduled_at is set, else "instant".'], 'new_title' => ['type' => 'string', 'description' => 'duplicate only — overrides the auto "[Duplicate] X" title.'], ], 'required' => ['campaign_id', 'action'], ], 'execute_callback' => [CampaignTools::class, 'changeCampaignStatus'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_manage_emails'); }, 'annotations' => ['destructive' => true], ], 'fluent-crm/update-contact-automation-status' => [ 'label' => __('Update Contact Automation Status', 'fluent-crm'), 'description' => __('Resume, cancel, or advance_now a contact in a funnel. cancel is destructive (reversible in UI but halts processing). advance_now requires advance_to_sequence_id and skips intermediate benchmarks.', 'fluent-crm'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'funnel_id' => ['type' => 'integer', 'description' => 'Use list-funnel-subscribers to find candidates.'], 'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'], 'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'], 'action' => ['type' => 'string', 'enum' => ['resume', 'cancel', 'advance_now']], 'advance_to_sequence_id' => ['type' => 'integer', 'description' => 'Required when action=advance_now. The sequence id to jump to (find via get-automation include=["sequences"]).'], ], 'required' => ['funnel_id', 'action'], ], 'execute_callback' => [FunnelTools::class, 'updateContactAutomationStatus'], 'permission_callback' => function () { return PermissionManager::currentUserCan('fcrm_write_funnels'); }, ], ]; } public static function register() { foreach (self::getDefinitions() as $name => $definition) { $args = [ 'label' => $definition['label'], 'description' => $definition['description'], 'category' => 'fluent-crm', 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback']), 'permission_callback' => $definition['permission_callback'], 'meta' => [ 'show_in_rest' => true, 'mcp' => [ 'public' => true, ], ], ]; if (!empty($definition['input_schema'])) { $args['input_schema'] = $definition['input_schema']; } if (!empty($definition['annotations'])) { $args['meta']['annotations'] = $definition['annotations']; } wp_register_ability($name, $args); } } /** * Wraps every tool's execute callback in a try/catch that converts * unhandled exceptions (SQL errors, type errors, anything that escapes * a tool's own validation) into a structured WP_Error with the actual * exception message instead of the adapter's generic "Tool execution * failed" surface. Without this, the agent has no signal about what * went wrong, which leads to retries against tools that silently * succeeded — see fluentcrm-mcp-review.md bug #1. */ private static function wrapExecuteCallback($toolName, $callback) { return function ($params) use ($toolName, $callback) { try { return call_user_func($callback, $params); } catch (\Throwable $e) { /** * Allows logging or alerting on unhandled tool exceptions * before the structured error is returned to the agent. * * @since 2.10.0 * * @param \Throwable $e The exception. * @param string $toolName Fully-qualified ability name. * @param mixed $params The tool's input parameters. */ do_action('fluent_crm/mcp_tool_exception', $e, $toolName, $params); $details = [ 'tool' => $toolName, 'exception' => get_class($e), ]; if (defined('WP_DEBUG') && WP_DEBUG) { $details['file'] = $e->getFile() . ':' . $e->getLine(); $details['trace'] = array_slice(explode("\n", $e->getTraceAsString()), 0, 5); } return new \WP_Error('failed', $e->getMessage(), $details); } }; } }