PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.13
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.13
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / AbilitiesRegistrar.php

AbilitiesRegistrar.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.13, at app/Modules/MCP/AbilitiesRegistrar.php

250 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Modules\MCP\Support\ErrorCodes;
8 use FluentForm\App\Modules\MCP\Support\MCPHelper;
9 use FluentForm\App\Modules\MCP\Support\PermissionGate;
10 use FluentForm\App\Modules\MCP\Tools\ContextTools;
11 use FluentForm\App\Modules\MCP\Tools\FormTools;
12 use FluentForm\App\Modules\MCP\Tools\SubmissionTools;
13 use FluentForm\App\Modules\MCP\Tools\ReportTools;
14 use FluentForm\App\Modules\MCP\Tools\IntegrationTools;
15 use FluentForm\App\Modules\MCP\Tools\StylingTools;
16 use FluentForm\App\Modules\MCP\Tools\FieldTools;
17 use FluentForm\App\Modules\MCP\Tools\NotificationTools;
18
19 /**
20 * Single source of truth for every FluentForm MCP ability.
21 *
22 * Each tool class owns its own definitions() slice (schema next to code); this
23 * class merges them, wraps every execute_callback so unhandled exceptions become
24 * structured WP_Errors the agent can read (instead of the adapter's generic
25 * "Tool execution failed"), and registers each as a WP ability.
26 *
27 * Pro tools are NOT listed here — they push abilities via the
28 * fluentform/mcp_loaded action + fluentform/mcp_ability_names filter.
29 */
30 class AbilitiesRegistrar
31 {
32 private static function toolClasses()
33 {
34 return [
35 ContextTools::class,
36 FormTools::class,
37 SubmissionTools::class,
38 ReportTools::class,
39 IntegrationTools::class,
40 StylingTools::class,
41 FieldTools::class,
42 NotificationTools::class,
43 ];
44 }
45
46 public static function getDefinitions()
47 {
48 $defs = [];
49
50 foreach (self::toolClasses() as $class) {
51 if (class_exists($class) && method_exists($class, 'definitions')) {
52 $defs = array_merge($defs, (array) $class::definitions());
53 }
54 }
55
56 /**
57 * Filter the full MCP tool-definition map (name => definition). The one
58 * unified seam for FluentForm Pro to inject a new tool or override an
59 * existing definition; must return the map array.
60 *
61 * @since 6.2.5
62 *
63 * @param array $defs Map of ability name to definition.
64 */
65 $filtered = apply_filters('fluentform/mcp_tool_definitions', $defs);
66
67 return is_array($filtered) ? $filtered : $defs;
68 }
69
70 /**
71 * The agent-facing catalogue: each ability's display metadata, projected
72 * from its own definition (group declared inline; read/write derived from
73 * the readonly annotation). The single source the settings card reads, so
74 * the UI can never drift from what the server actually exposes.
75 */
76 public static function catalogue()
77 {
78 $out = [];
79 $present = [];
80 foreach (self::getDefinitions() as $name => $def) {
81 $present[$name] = true;
82 $out[] = [
83 'name' => $name,
84 'label' => isset($def['label']) ? $def['label'] : $name,
85 'description' => isset($def['description']) ? $def['description'] : '',
86 'group' => isset($def['group']) ? $def['group'] : __('General', 'fluentform'),
87 'write' => empty($def['annotations']['readonly']),
88 'pro' => !empty($def['pro']),
89 'available' => true,
90 ];
91 }
92
93 // When Pro is inactive its advanced-report abilities are not registered;
94 // surface them as greyed "Pro" teasers so admins see what upgrading adds.
95 // Settings-card only (this catalogue), never the agent-facing server list.
96 foreach (self::advancedToolTeasers() as $teaser) {
97 if (isset($present[$teaser['name']])) {
98 continue;
99 }
100 $out[] = [
101 'name' => $teaser['name'],
102 'label' => $teaser['label'],
103 'description' => isset($teaser['description']) ? $teaser['description'] : '',
104 'group' => $teaser['group'],
105 'write' => false,
106 'pro' => true,
107 'available' => false,
108 ];
109 }
110
111 return $out;
112 }
113
114 /**
115 * Display-only teasers for the Pro Advanced Reporting tools, listed (greyed,
116 * "Pro") in the settings card when Pro is inactive. NEVER registered as
117 * abilities — they must not reach getDefinitions()/wp_register_ability or the
118 * agent server. Names MUST match fluentformpro McpReportTools::definitions()
119 * (kept in lockstep by a test), so the live tool takes over the same row once
120 * Pro activates.
121 */
122 public static function advancedToolTeasers()
123 {
124 return [
125 ['name' => 'fluentform/get-revenue-analysis', 'label' => __('Get Revenue Analysis', 'fluentform'), 'group' => __('Reports', 'fluentform')],
126 ['name' => 'fluentform/get-completion-rate', 'label' => __('Get Completion Rate', 'fluentform'), 'group' => __('Reports', 'fluentform')],
127 ['name' => 'fluentform/get-subscription-report', 'label' => __('Get Subscription Report', 'fluentform'), 'group' => __('Reports', 'fluentform')],
128 ];
129 }
130
131 public static function register()
132 {
133 foreach (self::getDefinitions() as $name => $definition) {
134 $permissionCallback = self::permissionCallback($definition);
135
136 // Filter-injected definitions are untrusted shape-wise: without an
137 // execute callback and a permission source the ability is
138 // uncallable or ungated — skip, don't fatal.
139 if (empty($definition['execute_callback']) || !$permissionCallback) {
140 continue;
141 }
142
143 $args = [
144 'label' => isset($definition['label']) ? $definition['label'] : $name,
145 'description' => isset($definition['description']) ? $definition['description'] : '',
146 'category' => 'fluentform',
147 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback']),
148 'permission_callback' => $permissionCallback,
149 'meta' => [
150 // OFF by default. show_in_rest opts an ability into WP core's
151 // own surface — POST /wp-json/wp-abilities/v1/abilities/{name}/run
152 // — which is a second entry path that never runs
153 // PermissionGate::transport(). It is not a privilege
154 // escalation (each ability's permission_callback still runs,
155 // and it is narrower than the transport check), but it means
156 // enabling MCP would also expose all of these tools to any
157 // authenticated browser session, which is not what the
158 // settings card advertises.
159 //
160 // The MCP adapter itself reads only the 'mcp' meta below, so
161 // turning this off costs the MCP endpoint nothing.
162 'show_in_rest' => (bool) apply_filters('fluentform/mcp_show_in_rest', false),
163 'mcp' => ['public' => true],
164 ],
165 ];
166
167 if (!empty($definition['input_schema'])) {
168 $args['input_schema'] = $definition['input_schema'];
169 }
170
171 if (!empty($definition['output_schema'])) {
172 $args['output_schema'] = $definition['output_schema'];
173 }
174
175 if (!empty($definition['annotations'])) {
176 $args['meta']['annotations'] = $definition['annotations'];
177 }
178
179 wp_register_ability($name, $args);
180 }
181 }
182
183 /**
184 * The gate for one ability: an explicit permission_callback wins (the seam
185 * for filter-injected tools with custom logic); otherwise a 'capability'
186 * key — one cap or an any-of list — is wrapped in the standard
187 * PermissionGate check. Null means the definition declared neither.
188 *
189 * @return callable|null
190 */
191 public static function permissionCallback($definition)
192 {
193 if (!empty($definition['permission_callback'])) {
194 return $definition['permission_callback'];
195 }
196
197 if (empty($definition['capability'])) {
198 return null;
199 }
200
201 $capabilities = (array) $definition['capability'];
202
203 return function () use ($capabilities) {
204 return PermissionGate::canAny($capabilities);
205 };
206 }
207
208 /**
209 * Convert any unhandled \Throwable from a tool into a structured WP_Error
210 * carrying the real message (and, under WP_DEBUG, the file + a short trace).
211 * Without this the agent only sees the adapter's generic failure surface and
212 * retries blindly against tools that may have partially succeeded.
213 */
214 private static function wrapExecuteCallback($toolName, $callback)
215 {
216 return function ($params) use ($toolName, $callback) {
217 try {
218 return call_user_func($callback, $params);
219 } catch (\Throwable $e) {
220 /**
221 * Fires when an MCP tool throws. Lets sites log/alert before the
222 * structured error reaches the agent.
223 *
224 * @since 6.2.5
225 *
226 * @param array $context { exception: \Throwable, tool: string, params: mixed }
227 */
228 do_action('fluentform/mcp_tool_exception', [
229 'exception' => $e,
230 'tool' => $toolName,
231 'params' => $params,
232 ]);
233
234 $details = ['tool' => $toolName, 'exception' => get_class($e), 'retryable' => true];
235
236 // File/line/trace help an operator but this payload reaches the
237 // remote agent — raw paths would leak the server layout. Off by
238 // default, opt-in, and reduced to a basename when on.
239 $exposeDetails = apply_filters('fluentform/mcp_expose_error_details', false);
240 if ($exposeDetails) {
241 $details['file'] = basename($e->getFile()) . ':' . $e->getLine();
242 $details['trace'] = array_slice(explode("\n", $e->getTraceAsString()), 0, 5);
243 }
244
245 return MCPHelper::error(ErrorCodes::TOOL_FAILED, $e->getMessage(), $details);
246 }
247 };
248 }
249 }
250