PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Modules / MCP / AbilitiesRegistrar.php

AbilitiesRegistrar.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Modules/MCP/AbilitiesRegistrar.php

537 lines 46.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\MCP;
4
5 use FluentSupport\App\Modules\MCP\Support\AbilityGuard;
6 use FluentSupport\App\Modules\MCP\Tools\TicketTools;
7 use FluentSupport\App\Modules\MCP\Tools\ResponseTools;
8 use FluentSupport\App\Modules\MCP\Tools\ManagementTools;
9 use FluentSupport\App\Modules\MCP\Tools\CustomerTools;
10
11 /**
12 * Tool descriptions are English and not translatable: the model reads them, and they
13 * name exact parameters and values a translation could break. Labels stay
14 * translatable because people see them in the admin and in client approval prompts.
15 */
16 class AbilitiesRegistrar
17 {
18 /** Tools that need Fluent Support Pro; the server only lists them when Pro is active. */
19 const PRO_ONLY = [
20 'fluent-support/merge-tickets',
21 'fluent-support/split-ticket',
22 ];
23
24 private static $definitions = null;
25
26 public static function getDefinitions(): array
27 {
28 if (self::$definitions !== null) {
29 return self::$definitions;
30 }
31
32 self::$definitions = [
33 'fluent-support/list-tickets' => [
34 'label' => __('List Support Tickets', 'fluent-support'),
35 'description' => 'List and filter tickets. Each row has a content preview, customer, agent, product, tags, waiting_on and response_count, so you can triage a page without opening tickets. For triage: waiting_for_reply=true, sort_by=waiting_since, order=ASC. For stale tickets: waiting_on=customer with the same sort. customer_summary appears only when an integration provides it. For one customer\'s tickets, use get-customer-tickets. When you reply to or close tickets during a multi-page sweep, pass meta.paging.next_cursor as cursor instead of incrementing page, because acting on tickets reorders the list.',
36 'input_schema' => [
37 'type' => 'object',
38 'properties' => [
39 'status' => ['type' => 'string', 'enum' => ['open', 'closed', 'new', 'active', 'all'], 'description' => 'Filter by status: open (new and active), new, active, closed or all'],
40 'agent_id' => ['type' => 'integer', 'description' => 'Filter by assigned agent ID'],
41 'product_id' => ['type' => 'integer', 'description' => 'Filter by product ID'],
42 'mailbox_id' => ['type' => 'integer', 'description' => 'Filter by mailbox ID'],
43 'priority' => ['type' => 'string', 'enum' => ['normal', 'medium', 'critical'], 'description' => 'Filter by the priority agents set'],
44 'client_priority' => ['type' => 'string', 'enum' => ['normal', 'medium', 'critical'], 'description' => 'Filter by the priority the customer chose when submitting the ticket'],
45 'tags' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Filter by tag IDs'],
46 'waiting_for_reply' => ['type' => 'boolean', 'description' => 'Only tickets waiting on an agent reply (waiting_on="agent"). Implies status=open unless status is set.'],
47 'waiting_on' => ['type' => 'string', 'enum' => ['agent', 'customer'], 'description' => 'Only open tickets waiting on an agent (same as waiting_for_reply=true) or on the customer. waiting_on=customer with sort_by=waiting_since and order=ASC lists the tickets idle longest since our reply.'],
48 'unassigned' => ['type' => 'boolean', 'description' => 'Only show tickets with no agent assigned'],
49 'needs_first_response' => ['type' => 'boolean', 'description' => 'Only show open tickets that have never had a reply (response_count=0). Combine with unassigned=true for a new-and-untouched queue view.'],
50 'customer_id' => ['type' => 'integer', 'description' => 'Filter by customer ID'],
51 'search' => ['type' => 'string', 'description' => 'Full-text search across ticket title, content, conversation messages, and customer name/email'],
52 'created_after' => ['type' => 'string', 'description' => 'Only tickets created on or after this date (YYYY-MM-DD or ISO 8601). A bare date is interpreted as midnight in the site\'s configured timezone (Settings > General), not UTC; an explicit UTC offset in the value is not converted before comparison, so omit it to match site time.'],
53 'created_before' => ['type' => 'string', 'description' => 'Only tickets created on or before this date (YYYY-MM-DD or ISO 8601). A bare date is interpreted as midnight in the site\'s configured timezone (Settings > General), not UTC; an explicit UTC offset in the value is not converted before comparison, so omit it to match site time.'],
54 'page' => ['type' => 'integer', 'default' => 1, 'description' => 'Page number'],
55 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Results per page (max 100)'],
56 'sort_by' => ['type' => 'string', 'default' => 'id', 'enum' => ['id', 'created_at', 'updated_at', 'waiting_since', 'status', 'priority', 'client_priority', 'response_count'], 'description' => 'Sort column'],
57 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC', 'description' => 'Sort direction. Alias: order'],
58 'order' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'description' => 'Alias for sort_type'],
59 'cursor' => ['type' => 'string', 'description' => 'Opaque continuation token from a previous response\'s meta.paging.next_cursor. When set, returns rows strictly after that position using the same sort_by/sort_type — stable even if earlier rows\' waiting_since changed. Overrides page when both are set.'],
60 ],
61 ],
62 'execute_callback' => [TicketTools::class, 'listTickets'],
63 'annotations' => ['readonly' => true, 'openWorldHint' => false],
64 ],
65
66 'fluent-support/get-ticket' => [
67 'label' => __('Get Ticket Details', 'fluent-support'),
68 'description' => 'Get a ticket with its conversation, the customer\'s history and previous tickets, custom fields, and purchase and CRM data from integrations. Content is Markdown; the customer\'s opening message is in content. The conversation comes in pages of 50 messages: page 1 holds the latest 50, and each page reads oldest to newest, like a chat. Use response_page=2 for the 50 before those when responses_meta.has_more is true, or since_response_id to get only messages newer than one you have read. Response types: response (seen by the customer; person.type says who wrote it), note (agents only), internal_info (activity lines such as assignments and closures), draft_response (unsent). Attachment links expire at url_expires_at. reply_email is the email the customer gets when you reply from this inbox.',
69 'input_schema' => [
70 'type' => 'object',
71 'properties' => [
72 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
73 'with_responses' => ['type' => 'boolean', 'default' => true, 'description' => 'Include conversation thread (default: true). Set false to get only ticket metadata.'],
74 'response_page' => ['type' => 'integer', 'default' => 1, 'description' => 'Page of the conversation: 1 is the latest 50 messages, 2 the 50 before those. Use it when responses_meta.has_more is true.'],
75 'since_response_id' => ['type' => 'integer', 'description' => 'Only return responses newer than this ID. Pass responses_meta.latest_response_id from your last call to read only new messages.'],
76 'response_id' => ['type' => 'integer', 'description' => 'Return only this response, e.g. to read a truncated one in full.'],
77 'max_chars_per_response' => ['type' => 'integer', 'description' => 'Truncate each body, including the opening message, to this many characters. Truncated entries have truncated=true and full_length. Default: no limit.'],
78 'with_integrations' => ['type' => ['boolean', 'array'], 'items' => ['type' => 'string'], 'default' => true, 'description' => 'Integration data such as purchase history. true (default), false to skip, or a list of widget keys (the keys of integrations, e.g. ["woo_purchases"]) to get only those.'],
79 ],
80 'required' => ['ticket_id'],
81 ],
82 'execute_callback' => [TicketTools::class, 'getTicket'],
83 'annotations' => ['readonly' => true, 'openWorldHint' => false],
84 ],
85
86 'fluent-support/create-ticket' => [
87 'label' => __('Create Support Ticket', 'fluent-support'),
88 'description' => 'Create a new support ticket on behalf of a customer. Requires title, content, and customer_email. Automatically creates the customer if the email is new. By default the ticket is logged on the customer\'s behalf ("created by agent") — the customer gets a notification that a ticket was opened for them. Set agent_initiated=true only when the agent is proactively reaching out to start a conversation (e.g. billing follow-up, onboarding, account review); then the content is sent to the customer as the agent\'s first reply instead. Use get-support-context first to get valid product_id, mailbox_id, and agent_id values.',
89 'input_schema' => [
90 'type' => 'object',
91 'properties' => [
92 'title' => ['type' => 'string', 'description' => 'Ticket title/subject'],
93 'content' => ['type' => 'string', 'description' => 'Ticket message content. Format controlled by content_format (default: markdown).'],
94 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
95 'customer_email' => ['type' => 'string', 'format' => 'email', 'description' => 'Customer email address'],
96 'customer_first_name' => ['type' => 'string', 'description' => 'Customer first name (for new customers)'],
97 'customer_last_name' => ['type' => 'string', 'description' => 'Customer last name (for new customers)'],
98 'priority' => ['type' => 'string', 'enum' => ['normal', 'medium', 'critical'], 'description' => 'Ticket priority'],
99 'product_id' => ['type' => 'integer', 'description' => 'Product ID'],
100 'mailbox_id' => ['type' => 'integer', 'description' => 'Mailbox ID'],
101 'agent_id' => ['type' => 'integer', 'description' => 'Assign to agent ID'],
102 'agent_initiated' => ['type' => 'boolean', 'description' => 'Optional, default false. Set true ONLY when the agent is proactively starting the conversation (the content is then sent to the customer as the agent\'s first reply, and the "ticket created by agent" email is skipped). Leave false/unset to log a ticket on the customer\'s behalf.'],
103 ],
104 'required' => ['title', 'content', 'customer_email'],
105 ],
106 'execute_callback' => [TicketTools::class, 'createTicket'],
107 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false, 'openWorldHint' => true],
108 ],
109
110 'fluent-support/reply-to-ticket' => [
111 'label' => __('Reply to Ticket', 'fluent-support'),
112 'description' => 'Send a customer-visible reply. An unassigned ticket is assigned to you unless assignee_id is set. A closed ticket is reopened by the reply (reopened=true). Set draft=true when a human should review the reply before it is sent. Set informational=true for a holding message that does not answer the customer. Attach files with attachments. Before writing, read the reply_email template on get-ticket (templates are per inbox) and leave out any greeting, sign-off or signature it already has. To close without replying, use close-ticket.',
113 'input_schema' => [
114 'type' => 'object',
115 'properties' => [
116 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
117 'content' => ['type' => 'string', 'description' => 'Response message content (visible to customer). Format controlled by content_format (default: markdown). Not required if saved_reply_id is provided.'],
118 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
119 'saved_reply_id' => ['type' => 'integer', 'description' => 'Use a saved reply by ID (use list-saved-replies to find IDs). Content overrides saved reply if both provided.'],
120 'assignee_id' => ['type' => 'integer', 'description' => 'Optional. Assign the ticket to this agent as part of the reply (requires the "Assign Agents" permission; use get-support-context for agent IDs). If omitted and the ticket is unassigned, it is auto-assigned to you.'],
121 'close_ticket' => ['type' => 'boolean', 'default' => false, 'description' => 'Close the ticket after replying. A closed ticket stays closed. Not allowed with draft.'],
122 'draft' => ['type' => 'boolean', 'default' => false, 'description' => 'Save as an unsent draft for a human to approve in the ticket view. Status and assignee are unchanged; close_ticket and assignee_id are not allowed.'],
123 'informational' => ['type' => 'boolean', 'default' => false, 'description' => 'A holding reply that does not answer the customer yet, such as "I have passed this to our billing team": it is sent as usual, but waiting_on stays "agent". Not allowed with draft.'],
124 'attachments' => self::attachmentsSchema(),
125 ],
126 'required' => ['ticket_id'],
127 ],
128 'execute_callback' => [ResponseTools::class, 'replyToTicket'],
129 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false, 'openWorldHint' => true],
130 ],
131
132 'fluent-support/close-ticket' => [
133 'label' => __('Close Ticket', 'fluent-support'),
134 'description' => 'Close a ticket, optionally with a final reply to the customer and an internal note in the same call. Use this instead of reply-to-ticket when closing is the goal. Closing a ticket that is already closed changes nothing and sends nothing, so retrying is safe.',
135 'input_schema' => [
136 'type' => 'object',
137 'properties' => [
138 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
139 'reply_content' => ['type' => 'string', 'description' => 'Optional final reply visible to the customer (sent before closing). Format controlled by content_format (default: markdown).'],
140 'internal_note' => ['type' => 'string', 'description' => 'Optional internal note for the closure (visible to agents only). Format controlled by content_format (default: markdown).'],
141 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of reply_content and internal_note: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
142 'silent' => ['type' => 'boolean', 'default' => false, 'description' => 'Skip the "ticket closed" email and the automations that run on closing, like "Close Silently" in the ticket view. Use it whenever the customer should not get a closing email, for example spam, duplicates or a ticket already resolved elsewhere. A reply_content in the same call is still sent.'],
143 ],
144 'required' => ['ticket_id'],
145 ],
146 'execute_callback' => [TicketTools::class, 'closeTicket'],
147 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true, 'openWorldHint' => true],
148 ],
149
150 'fluent-support/update-ticket' => [
151 'label' => __('Update Ticket Properties', 'fluent-support'),
152 'description' => 'Change a ticket: title, priority, status, product, mailbox or assigned agent, several at once. Assigning notifies the agent as in the ticket view. Setting status=active on a closed ticket reopens it. To close a ticket, use close-ticket. IDs come from get-support-context.',
153 'input_schema' => [
154 'type' => 'object',
155 'properties' => [
156 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
157 'title' => ['type' => 'string', 'description' => 'New ticket title'],
158 'priority' => ['type' => 'string', 'enum' => ['normal', 'medium', 'critical'], 'description' => 'New priority'],
159 'status' => ['type' => 'string', 'enum' => ['new', 'active'], 'description' => 'New status: new or active. On a closed ticket, active reopens it. To close a ticket, use close-ticket.'],
160 'product_id' => ['type' => 'integer', 'description' => 'New product ID (use get-support-context to find IDs)'],
161 'mailbox_id' => ['type' => 'integer', 'description' => 'Move the ticket to this mailbox (use get-support-context to find IDs, requires the "Manage Overall Settings" permission)'],
162 'agent_id' => ['type' => 'integer', 'description' => 'Assign to agent ID (use get-support-context to find IDs, requires the "Assign Agents" permission)'],
163 ],
164 'required' => ['ticket_id'],
165 ],
166 'execute_callback' => [TicketTools::class, 'updateTicket'],
167 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true, 'openWorldHint' => false],
168 ],
169
170 'fluent-support/delete-ticket' => [
171 'label' => __('Delete Ticket', 'fluent-support'),
172 'description' => 'Permanently delete a ticket with all its messages, attachments and data. This cannot be undone. To remove spam or duplicates from the queue, close them with close-ticket silent=true instead; delete only when a person asks you to.',
173 'input_schema' => [
174 'type' => 'object',
175 'properties' => [
176 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
177 ],
178 'required' => ['ticket_id'],
179 ],
180 'execute_callback' => [TicketTools::class, 'deleteTicket'],
181 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true, 'openWorldHint' => false],
182 ],
183
184 'fluent-support/get-ticket-activity' => [
185 'label' => __('Get Ticket Activity Log', 'fluent-support'),
186 'description' => 'Get the activity timeline for a ticket — who did what and when. Shows ticket creation, responses, status changes, agent assignments, and closures. Use to understand ticket history without reading the full conversation.',
187 'input_schema' => [
188 'type' => 'object',
189 'properties' => [
190 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
191 ],
192 'required' => ['ticket_id'],
193 ],
194 'execute_callback' => [TicketTools::class, 'getTicketActivity'],
195 'annotations' => ['readonly' => true, 'openWorldHint' => false],
196 ],
197
198 'fluent-support/merge-tickets' => [
199 'label' => __('Merge Tickets', 'fluent-support'),
200 'description' => 'Merge duplicate tickets into one. All conversations and attachments from the merged tickets are moved into the target ticket. The merged tickets are deleted.',
201 'input_schema' => [
202 'type' => 'object',
203 'properties' => [
204 'target_ticket_id' => ['type' => 'integer', 'description' => 'The ticket to merge into (this one survives)'],
205 'merge_ticket_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Ticket IDs to merge into the target (these get deleted)'],
206 ],
207 'required' => ['target_ticket_id', 'merge_ticket_ids'],
208 ],
209 'execute_callback' => [TicketTools::class, 'mergeTickets'],
210 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false, 'openWorldHint' => false],
211 ],
212
213 'fluent-support/split-ticket' => [
214 'label' => __('Split Ticket', 'fluent-support'),
215 'description' => 'Move one message out of a ticket into a new ticket, for example when a customer raises a second, unrelated issue in the same thread. The message and its attachments leave the original ticket and become the opening message of the new one; both tickets get an activity line. The new ticket goes to the same mailbox unless you pass mailbox_id, and the site\'s rules for new tickets (assignment, automations) apply to it; the original keeps its status. notify_customer=false (the default) holds back the inbox\'s new-ticket emails to the customer; automations set up for new tickets still run. Only messages of type response can be split.',
216 'input_schema' => [
217 'type' => 'object',
218 'properties' => [
219 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket that holds the message'],
220 'response_id' => ['type' => 'integer', 'description' => 'The message to move (a response ID from get-ticket)'],
221 'title' => ['type' => 'string', 'description' => 'Title of the new ticket. Default: "Re: " and the original title.'],
222 'content' => ['type' => 'string', 'description' => 'Opening message of the new ticket. Default: the moved message as written.'],
223 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content, if provided.'],
224 'customer_id' => ['type' => 'integer', 'description' => 'Customer of the new ticket. Default: the message author when a customer wrote it, else the ticket customer.'],
225 'mailbox_id' => ['type' => 'integer', 'description' => 'Mailbox of the new ticket. Default: the original ticket mailbox.'],
226 'product_id' => ['type' => 'integer', 'description' => 'Product of the new ticket. Default: the original ticket product.'],
227 'notify_customer' => ['type' => 'boolean', 'default' => false, 'description' => 'Send the customer the inbox\'s new-ticket emails for the new ticket. Default false: the split is silent for the customer.'],
228 ],
229 'required' => ['ticket_id', 'response_id'],
230 ],
231 'execute_callback' => [TicketTools::class, 'splitTicket'],
232 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false, 'openWorldHint' => true],
233 ],
234
235 'fluent-support/add-internal-note' => [
236 'label' => __('Add Internal Note', 'fluent-support'),
237 'description' => 'Add an internal note to a ticket, visible to agents only. To notify an agent, mention them as @[agent_id:Name], e.g. @[12:Sam Lee], with the ID from get-support-context. Attach files with attachments. The result\'s mentions says who you mentioned and whether they get notified.',
238 'input_schema' => [
239 'type' => 'object',
240 'properties' => [
241 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
242 'content' => ['type' => 'string', 'description' => 'Note content. Format controlled by content_format (default: markdown).'],
243 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
244 'attachments' => self::attachmentsSchema(),
245 ],
246 'required' => ['ticket_id', 'content'],
247 ],
248 'execute_callback' => [ResponseTools::class, 'addInternalNote'],
249 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false, 'openWorldHint' => false],
250 ],
251
252 'fluent-support/update-response' => [
253 'label' => __('Update Draft or Note', 'fluent-support'),
254 'description' => 'Replace the text of a draft reply or internal note you wrote. Nothing is sent; to send a draft, use publish-draft-reply. Sent replies and activity lines (type internal_info) cannot be changed.',
255 'input_schema' => [
256 'type' => 'object',
257 'properties' => [
258 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
259 'response_id' => ['type' => 'integer', 'description' => 'ID of your draft (type draft_response) or note (type note), from get-ticket.'],
260 'content' => ['type' => 'string', 'description' => 'New content. Format controlled by content_format (default: markdown).'],
261 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
262 ],
263 'required' => ['ticket_id', 'response_id', 'content'],
264 ],
265 'execute_callback' => [ResponseTools::class, 'updateResponse'],
266 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true, 'openWorldHint' => false],
267 ],
268
269 'fluent-support/delete-response' => [
270 'label' => __('Delete Draft or Note', 'fluent-support'),
271 'description' => 'Delete a draft reply or internal note you wrote. Sent replies and activity lines (type internal_info) cannot be deleted.',
272 'input_schema' => [
273 'type' => 'object',
274 'properties' => [
275 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
276 'response_id' => ['type' => 'integer', 'description' => 'ID of your draft (type draft_response) or note (type note), from get-ticket.'],
277 ],
278 'required' => ['ticket_id', 'response_id'],
279 ],
280 'execute_callback' => [ResponseTools::class, 'deleteResponse'],
281 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true, 'openWorldHint' => false],
282 ],
283
284 'fluent-support/publish-draft-reply' => [
285 'label' => __('Publish Draft Reply', 'fluent-support'),
286 'description' => 'Send a draft reply to the customer, the same as Approve in the ticket view. Optionally pass content to edit it first. The email goes out under the name of the agent who wrote the draft (From name and signature), matching the thread. Publishing does not reopen a closed ticket.',
287 'input_schema' => [
288 'type' => 'object',
289 'properties' => [
290 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
291 'response_id' => ['type' => 'integer', 'description' => 'The draft reply ID (type draft_response in get-ticket)'],
292 'content' => ['type' => 'string', 'description' => 'Optional. Replace the draft content before sending. Format controlled by content_format (default: markdown).'],
293 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
294 ],
295 'required' => ['ticket_id', 'response_id'],
296 ],
297 'execute_callback' => [ResponseTools::class, 'publishDraftReply'],
298 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false, 'openWorldHint' => true],
299 ],
300
301 'fluent-support/tag-ticket' => [
302 'label' => __('Add or Remove Ticket Tags', 'fluent-support'),
303 'description' => 'Add or remove tags on a ticket. Pass existing tag IDs, or tag names, which are matched to existing tags ignoring case; tags are created by people in the admin, so an unknown name is refused with the list of tags. For many tickets at once, use bulk-action with action=tag.',
304 'input_schema' => [
305 'type' => 'object',
306 'properties' => [
307 'ticket_id' => ['type' => 'integer', 'description' => 'The ticket ID'],
308 'add_tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing tag IDs to add'],
309 'add_tag_names' => ['type' => 'array', 'items' => ['type' => 'string'], 'maxItems' => 20, 'description' => 'Names of existing tags to add, matched ignoring case (max 20).'],
310 'remove_tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Tag IDs to remove'],
311 ],
312 'required' => ['ticket_id'],
313 ],
314 'execute_callback' => [ManagementTools::class, 'tagTicket'],
315 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true, 'openWorldHint' => false],
316 ],
317
318 'fluent-support/get-customer-tickets' => [
319 'label' => __('Get Customer Tickets', 'fluent-support'),
320 'description' => 'Get a customer\'s tickets by email, name, or ID — no need to search for the customer first. Email is the most reliable. Best tool when someone asks "show me tickets from [customer]".',
321 'input_schema' => [
322 'type' => 'object',
323 'properties' => [
324 'email' => ['type' => 'string', 'format' => 'email', 'description' => 'Customer email address (recommended — exact match)'],
325 'name' => ['type' => 'string', 'description' => 'Customer name (partial match on first/last name)'],
326 'customer_id' => ['type' => 'integer', 'description' => 'Customer ID (if already known)'],
327 'status' => ['type' => 'string', 'enum' => ['open', 'closed', 'new', 'active', 'all'], 'description' => 'Filter by status: open (new and active), new, active, closed or all'],
328 'page' => ['type' => 'integer', 'default' => 1],
329 'per_page' => ['type' => 'integer', 'default' => 15],
330 ],
331 ],
332 'execute_callback' => [CustomerTools::class, 'getCustomerTickets'],
333 'annotations' => ['readonly' => true, 'openWorldHint' => false],
334 ],
335
336 'fluent-support/get-support-context' => [
337 'label' => __('Get Support Context', 'fluent-support'),
338 'description' => 'START HERE: call this once at the start of a session. Returns your agent identity, whether your replies are sent or saved as drafts (replies), your signature, your queue, tickets that need attention, reference data with IDs (agents, products, mailboxes, tags), ticket counts, valid priority and status values, and guidelines. Each mailbox shows the templates of its reply and outreach emails, and you.signature shows your signature. my_queue.awaiting_your_reply lists your tickets waiting on you, oldest first (first 10); my_queue.awaiting_customer lists the ones waiting on the customer (first 5); each has a _total and a _truncated flag. needs_attention lists open tickets that are unassigned or critical, plus the ones waiting longest on an agent and those waiting on an agent past the SLA targets. Rows carry waiting_on and response_count, so open a ticket only when you need its conversation.',
339 'input_schema' => [
340 'type' => 'object',
341 'properties' => new \stdClass(),
342 ],
343 'execute_callback' => [ManagementTools::class, 'getSupportContext'],
344 'annotations' => ['readonly' => true, 'openWorldHint' => false],
345 ],
346
347 'fluent-support/list-saved-replies' => [
348 'label' => __('List Saved Replies', 'fluent-support'),
349 'description' => 'List your saved/canned replies. Only returns replies you created. Optionally filter by search text or product_id. Use the returned ID with reply-to-ticket\'s saved_reply_id param. Content may contain placeholders like {{customer.first_name}} or {{agent.first_name}}, expanded automatically when the reply is sent.',
350 'input_schema' => [
351 'type' => 'object',
352 'properties' => [
353 'search' => ['type' => 'string', 'description' => 'Search in title and content'],
354 'product_id' => ['type' => 'integer', 'description' => 'Filter by product ID'],
355 ],
356 ],
357 'execute_callback' => [ResponseTools::class, 'listSavedReplies'],
358 'annotations' => ['readonly' => true, 'openWorldHint' => false],
359 ],
360
361 'fluent-support/get-support-insights' => [
362 'label' => __('Get Support Insights', 'fluent-support'),
363 'description' => 'Get team performance for a period: tickets created and closed, first-response and resolution times, and per-agent numbers. from and to give the exact window. agent_performance lists agents by open tickets, most first, up to 50 (agent_performance_total gives the full count); pass agent_id for one agent. waiting and each agent\'s open tickets describe right now, not the period. waiting is split into waiting_on_agent (a reply is owed) and waiting_on_customer (we answered and the customer has not come back); report them separately, since a blended average mostly measures customer silence. Durations read like "2h 30m". For simple ticket counts, use get-support-context.',
364 'input_schema' => [
365 'type' => 'object',
366 'properties' => [
367 'period' => ['type' => 'string', 'enum' => ['24h', '7d', '30d', '90d'], 'default' => '7d', 'description' => 'Time period for volume and response time stats'],
368 'agent_id' => ['type' => 'integer', 'description' => 'Only this agent in agent_performance'],
369 ],
370 ],
371 'execute_callback' => [ManagementTools::class, 'getSupportInsights'],
372 'annotations' => ['readonly' => true, 'openWorldHint' => false],
373 ],
374
375 'fluent-support/search-customers' => [
376 'label' => __('Search Customers', 'fluent-support'),
377 'description' => 'Search for customers by name or email. Returns customer profiles with location and account age. Use get-customer-tickets if you already know which customer and want their tickets. Use this when you need to find or identify a customer first.',
378 'input_schema' => [
379 'type' => 'object',
380 'properties' => [
381 'search' => ['type' => 'string', 'description' => 'Search by name or email (partial match)'],
382 'page' => ['type' => 'integer', 'default' => 1],
383 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Results per page (max 100)'],
384 ],
385 ],
386 'execute_callback' => [CustomerTools::class, 'searchCustomers'],
387 'annotations' => ['readonly' => true, 'openWorldHint' => false],
388 ],
389
390 'fluent-support/update-customer' => [
391 'label' => __('Update Customer', 'fluent-support'),
392 'description' => 'Change a customer\'s profile: name, title, note or address. Only the fields you pass change; an empty string clears a field, except first_name. The email address cannot be changed here: if a customer asks to change it, or to send their emails somewhere else, leave an internal note and ask a person on your team to do it.',
393 'input_schema' => [
394 'type' => 'object',
395 'properties' => [
396 'customer_id' => ['type' => 'integer', 'description' => 'The customer ID (from search-customers or get-ticket)'],
397 'first_name' => ['type' => 'string'],
398 'last_name' => ['type' => 'string'],
399 'title' => ['type' => 'string', 'description' => 'Job title'],
400 'note' => ['type' => 'string', 'description' => 'Note about the customer that agents see on the profile. Replaces the current note.'],
401 'address_line_1' => ['type' => 'string'],
402 'address_line_2' => ['type' => 'string'],
403 'city' => ['type' => 'string'],
404 'state' => ['type' => 'string'],
405 'zip' => ['type' => 'string'],
406 'country' => ['type' => 'string', 'description' => 'Two-letter country code, e.g. US'],
407 ],
408 'required' => ['customer_id'],
409 ],
410 'execute_callback' => [CustomerTools::class, 'updateCustomer'],
411 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true, 'openWorldHint' => false],
412 ],
413
414 'fluent-support/get-mentions' => [
415 'label' => __('Get Agent Mentions', 'fluent-support'),
416 'description' => 'Get tickets where you have been @mentioned in internal notes or replies. Returns mention notifications with the ticket context, who mentioned you, and a content preview. Filter by read/unread status or scope to a specific ticket. Requires internal notifications to be enabled in settings.',
417 'input_schema' => [
418 'type' => 'object',
419 'properties' => [
420 'status' => ['type' => 'string', 'enum' => ['all', 'unread', 'read'], 'default' => 'all', 'description' => 'Filter by read status'],
421 'ticket_id' => ['type' => 'integer', 'description' => 'Scope mentions to a specific ticket ID'],
422 'page' => ['type' => 'integer', 'default' => 1, 'description' => 'Page number'],
423 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Results per page (max 100)'],
424 ],
425 ],
426 'execute_callback' => [ManagementTools::class, 'getMentions'],
427 'annotations' => ['readonly' => true, 'openWorldHint' => false],
428 ],
429
430 'fluent-support/bulk-action' => [
431 'label' => __('Bulk Ticket Action', 'fluent-support'),
432 'description' => 'Act on multiple tickets at once: close, assign, tag or reply. action=close emails every customer the closing email unless you set silent=true. More efficient than calling individual tools in a loop. Returns per-ticket results showing what succeeded or failed. reply sends the same content to every ticket, each exactly as reply-to-ticket would (closed tickets reopen, draft and close_ticket apply to all); placeholders such as {{customer.first_name}} are filled per customer. Maximum 50 ticket IDs per call; split larger sets into multiple requests.',
433 'input_schema' => [
434 'type' => 'object',
435 'properties' => [
436 'ticket_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'maxItems' => 50, 'description' => 'Array of ticket IDs (max 50 per request)'],
437 'action' => ['type' => 'string', 'enum' => ['close', 'assign', 'tag', 'reply'], 'description' => 'Action to perform'],
438 'agent_id' => ['type' => 'integer', 'description' => 'Agent ID (required for assign action)'],
439 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Tag IDs (required for tag action)'],
440 'content' => ['type' => 'string', 'description' => 'Reply content (reply action; or use saved_reply_id).'],
441 'content_format' => ['type' => 'string', 'enum' => ['markdown', 'html', 'text'], 'default' => 'markdown', 'description' => 'Format of content: markdown (default), html (as-is) or text (plain text, wrapped in paragraphs).'],
442 'saved_reply_id' => ['type' => 'integer', 'description' => 'Saved reply to send (reply action).'],
443 'close_ticket' => ['type' => 'boolean', 'default' => false, 'description' => 'Close each ticket after replying (reply action).'],
444 'draft' => ['type' => 'boolean', 'default' => false, 'description' => 'Save each reply as an unsent draft for a human to approve (reply action).'],
445 'silent' => ['type' => 'boolean', 'default' => false, 'description' => 'Close without the "ticket closed" email and closing automations, as close-ticket silent=true (close action). Without it, every customer gets the closing email.'],
446 ],
447 'required' => ['ticket_ids', 'action'],
448 ],
449 'execute_callback' => [ManagementTools::class, 'bulkAction'],
450 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false, 'openWorldHint' => true],
451 ],
452 ];
453
454 return self::$definitions;
455 }
456
457 /** Input schema for files sent with a reply or note. */
458 private static function attachmentsSchema()
459 {
460 return [
461 'type' => 'array',
462 'maxItems' => \FluentSupport\App\Modules\MCP\Support\AttachmentStager::maxFiles(),
463 'items' => [
464 'type' => 'object',
465 'properties' => [
466 'filename' => ['type' => 'string', 'description' => 'File name with extension, e.g. invoice.pdf'],
467 'content_base64' => ['type' => 'string', 'description' => 'File content, base64-encoded'],
468 ],
469 'required' => ['filename', 'content_base64'],
470 ],
471 'description' => 'Files to attach, base64-encoded. get-support-context lists the limits under uploads: how many files, the size of each, and the allowed extensions.',
472 ];
473 }
474
475 public static function register()
476 {
477 foreach (self::getDefinitions() as $name => $definition) {
478 $args = [
479 'label' => $definition['label'],
480 'description' => $definition['description'],
481 'category' => 'fluent-support',
482 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback']),
483 'permission_callback' => AbilityGuard::callbackFor($name),
484 'meta' => [
485 'show_in_rest' => true,
486 'mcp' => ['public' => true],
487 ],
488 ];
489
490 if (!empty($definition['input_schema'])) {
491 $args['input_schema'] = $definition['input_schema'];
492 }
493
494 if (!empty($definition['annotations'])) {
495 $args['meta']['annotations'] = $definition['annotations'];
496 }
497
498 wp_register_ability($name, $args);
499 }
500 }
501
502 /**
503 * Wrap an execute_callback so that:
504 * 1. Capability checks produce structured JSON (not the adapter's plain
505 * "Permission denied" string that fires when permission_callback returns false).
506 * 2. Uncaught exceptions become structured WP_Error responses.
507 */
508 private static function wrapExecuteCallback($abilityName, $callback)
509 {
510 return function ($params) use ($abilityName, $callback) {
511 if (!AbilityGuard::check($abilityName)) {
512 return \FluentSupport\App\Modules\MCP\Helpers\MCPHelper::error(
513 'forbidden',
514 __('You do not have permission to perform this action.', 'fluent-support'),
515 ['retryable' => false]
516 );
517 }
518
519 try {
520 return call_user_func($callback, $params);
521 } catch (\Throwable $e) {
522 do_action('fluent_support/mcp_tool_exception', [
523 'exception' => $e,
524 'tool' => $abilityName,
525 'params' => $params,
526 ]);
527
528 return \FluentSupport\App\Modules\MCP\Helpers\MCPHelper::error(
529 'internal_error',
530 __('An unexpected error occurred. Please try again or contact support.', 'fluent-support'),
531 ['retryable' => true]
532 );
533 }
534 };
535 }
536 }
537