PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.5.2
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.5.2
3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / includes / abilities / class-evf-mcp-server.php
everest-forms / includes / abilities Last commit date
class-evf-abilities-handlers.php 4 weeks ago class-evf-abilities-registry.php 4 weeks ago class-evf-abilities.php 4 weeks ago class-evf-field-schemas.php 4 weeks ago class-evf-form-builder.php 4 weeks ago class-evf-mcp-server.php 4 weeks ago
class-evf-mcp-server.php
421 lines
1 <?php
2 /**
3 * Minimal MCP-over-HTTP server endpoint for Everest Forms.
4 *
5 * Implements the subset of the Model Context Protocol JSON-RPC 2.0 surface
6 * needed for external clients (Claude Desktop, custom agents) to discover
7 * and invoke our registered abilities as MCP tools:
8 *
9 * - initialize
10 * - tools/list
11 * - tools/call
12 *
13 * Transport is plain HTTP POST against:
14 * POST /wp-json/everest-forms/v1/mcp
15 *
16 * Auth uses WordPress's normal REST auth (cookie, application password,
17 * or any plugin-provided REST auth scheme). The endpoint refuses
18 * unauthenticated callers.
19 *
20 * @package EverestForms\Abilities
21 */
22
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * MCP HTTP server.
27 */
28 class EVF_MCP_Server {
29
30 /**
31 * REST permission check.
32 *
33 * Authenticated users only. Per-tool checks are enforced again inside
34 * the ability registry, so unauthorized users can still hit the route
35 * but cannot invoke privileged tools.
36 *
37 * @return bool|WP_Error
38 */
39 public static function permission_check() {
40 if ( ! is_user_logged_in() ) {
41 return new WP_Error( 'evf_mcp_unauthorized', 'Authentication required.', array( 'status' => 401 ) );
42 }
43 return true;
44 }
45
46 /**
47 * REST callback.
48 *
49 * @param WP_REST_Request $request Request.
50 * @return WP_REST_Response
51 */
52 public static function handle_request( $request ) {
53 // GET returns a tiny capability summary, useful for sanity checks.
54 if ( 'GET' === $request->get_method() ) {
55 return new WP_REST_Response(
56 array(
57 'name' => 'everest-forms-mcp',
58 'version' => defined( 'EVF_VERSION' ) ? EVF_VERSION : '0.0.0',
59 'protocol' => '2024-11-05',
60 'transport' => 'http',
61 'capabilities' => array( 'tools' => array( 'listChanged' => false ) ),
62 ),
63 200
64 );
65 }
66
67 $body = $request->get_json_params();
68 if ( empty( $body ) || ! is_array( $body ) ) {
69 return self::jsonrpc_error( null, -32700, 'Parse error: invalid JSON body.' );
70 }
71
72 // Batch support per JSON-RPC 2.0.
73 if ( isset( $body[0] ) ) {
74 $responses = array();
75 foreach ( $body as $entry ) {
76 $resp = self::dispatch( $entry );
77 if ( null !== $resp ) {
78 $responses[] = $resp;
79 }
80 }
81 return new WP_REST_Response( $responses, 200 );
82 }
83
84 $resp = self::dispatch( $body );
85 return new WP_REST_Response( $resp, 200 );
86 }
87
88 /**
89 * Dispatch a single JSON-RPC envelope.
90 *
91 * @param array $msg JSON-RPC message.
92 * @return array|null Response envelope, or null for notifications.
93 */
94 protected static function dispatch( $msg ) {
95 $id = isset( $msg['id'] ) ? $msg['id'] : null;
96 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
97 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : array();
98
99 // Notifications (no id) get no response.
100 $is_notification = ! array_key_exists( 'id', $msg );
101
102 switch ( $method ) {
103 case 'initialize':
104 $result = array(
105 'protocolVersion' => '2024-11-05',
106 'serverInfo' => array(
107 'name' => 'everest-forms-mcp',
108 'version' => defined( 'EVF_VERSION' ) ? EVF_VERSION : '0.0.0',
109 ),
110 'capabilities' => array( 'tools' => new stdClass() ),
111 // Server-level guidance the client prepends to the model's
112 // context. This is the cross-cutting "knowledge base" for
113 // behaviors that no single tool description can carry —
114 // e.g. "create forms inactive by default", enum value
115 // mappings, exact field-type names. See self::instructions().
116 'instructions' => self::instructions(),
117 );
118 break;
119
120 case 'initialized':
121 case 'notifications/initialized':
122 return null;
123
124 case 'tools/list':
125 $result = array( 'tools' => self::tools_list() );
126 break;
127
128 case 'tools/call':
129 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
130 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array();
131 if ( '' === $name ) {
132 return self::jsonrpc_error( $id, -32602, 'Missing required parameter: name.' );
133 }
134 $result = self::tools_call( $name, $args );
135 if ( is_wp_error( $result ) ) {
136 return self::jsonrpc_error( $id, -32000, $result->get_error_message(), $result->get_error_data() );
137 }
138 break;
139
140 case 'ping':
141 $result = new stdClass();
142 break;
143
144 default:
145 if ( $is_notification ) {
146 return null;
147 }
148 return self::jsonrpc_error( $id, -32601, sprintf( 'Method not found: %s', $method ) );
149 }
150
151 if ( $is_notification ) {
152 return null;
153 }
154 return array(
155 'jsonrpc' => '2.0',
156 'id' => $id,
157 'result' => $result,
158 );
159 }
160
161 /**
162 * Build the MCP tools/list response from our ability registry.
163 *
164 * @return array
165 */
166 protected static function tools_list() {
167 $tools = array();
168 foreach ( EVF_Abilities_Registry::instance()->all() as $id => $ability ) {
169 $meta = self::ability_meta( $ability );
170 $bare = self::tool_name_from_id( $id );
171 $destructive = EVF_Abilities::is_destructive( $bare );
172
173 // MCP "annotations" hint to the client (Claude Desktop, etc.) how
174 // a tool behaves so the UI can render an appropriate confirmation
175 // prompt before invoking it. Without these, Claude Desktop treats
176 // every tool as safe and skips the Allow/Deny UI — which is what
177 // we want for read-only tools, but is dangerous for create/delete
178 // abilities.
179 //
180 // Spec: https://modelcontextprotocol.io/specification/server/tools#tool-annotations
181 $annotations = array(
182 'title' => isset( $meta['label'] ) && '' !== $meta['label'] ? $meta['label'] : ucwords( str_replace( '-', ' ', $bare ) ),
183 'readOnlyHint' => ! $destructive,
184 'destructiveHint' => $destructive,
185 // `idempotent` is true when calling the tool twice with the
186 // same args has no further effect. Read-only tools are
187 // idempotent by definition; for destructive ones we lean
188 // conservative and say no (better the client warns extra
189 // than warns too little).
190 'idempotentHint' => ! $destructive,
191 // `openWorldHint` = true means the tool reaches outside the
192 // host system (e.g., the public internet). Most EVF
193 // abilities act only on the local DB — false. The
194 // exception is activate-addon, which can pull resources
195 // during plugin activation, but it stays on the local site
196 // so still false.
197 'openWorldHint' => false,
198 );
199
200 $tools[] = array(
201 'name' => $bare,
202 'description' => $meta['description'],
203 'inputSchema' => $meta['input_schema'],
204 'annotations' => $annotations,
205 );
206 }
207 return $tools;
208 }
209
210 /**
211 * Server-level instructions returned on `initialize`.
212 *
213 * The MCP client prepends this to the model's context for the whole
214 * session. It's the home for cross-cutting rules that don't belong in any
215 * single tool's description: safe defaults, value mappings the model can't
216 * guess, and exact identifiers. Keep it tight — it costs tokens on every
217 * conversation (though clients cache it).
218 *
219 * Filterable so PRO/addons can append their own guidance.
220 *
221 * @return string
222 */
223 public static function instructions() {
224 $lines = array(
225 'You are operating Everest Forms (a WordPress form plugin) through these tools. Read these rules before calling any tool.',
226 '',
227 '## Identifiers — never guess',
228 '- Forms and entries are referenced by numeric ids. There is NO lookup-by-name. If the user names a form ("my contact form"), call list-forms first to find its id; do not invent one.',
229 '- To act on entries, get their ids from list-entries first. To inspect a form\'s fields/layout before editing, call get-form.',
230 '',
231 '## Form activation (IMPORTANT — strict default)',
232 '- Forms created with create-form are INACTIVE by default and must stay that way unless the user explicitly asks to publish, activate, or make the form live.',
233 '- Do NOT pass status:"publish" on create-form unless the user clearly requested it. When unsure, omit status (it defaults to inactive) and tell the user they can publish when ready.',
234 '- "Inactive" means the Active toggle is off (form_enabled=0) and post_status is "inactive"; the form will not accept submissions until published.',
235 '- Use update-form-status to move an existing form between publish / draft / trash.',
236 '',
237 '## Field types — use these EXACT ids (guessing causes errors)',
238 '- Core text: "text" (not "single-line"/"input"), "textarea" (not "paragraph"), "email", "url", "number", "phone".',
239 '- Names: "first-name" + "last-name" as two separate fields (there is no single "name" type).',
240 '- Choice types: "select" (not "dropdown"), "radio", "checkbox", "country" — these REQUIRE a non-empty `choices` array or validation fails.',
241 '- Structure/static: "date-time", "address", "file-upload", "image-upload", "hidden", "html", "divider", "title", "signature", "wysiwyg".',
242 '- Survey (needs Survey, Polls and Quiz addon): "likert", "scale-rating", "yes-no", "rating".',
243 '- Payment (needs PRO / Coupons): "payment-single", "payment-radio", "payment-checkbox", "payment-quantity", "payment-coupon", "payment-subtotal", "payment-total".',
244 '- If unsure a type exists or is usable on this site, call list-field-types or describe-field-type (check its usable_now flag) first.',
245 '',
246 '## Choices format (select / radio / checkbox / country)',
247 '- Pass `choices` as an array. Each item may be a bare string ("Red") or an object {label, value?, default?}.',
248 '- Example: choices: [ {"label":"Basic","value":"basic","default":true}, "Pro", "Enterprise" ].',
249 '',
250 '## Layout',
251 '- Prefer ONE create-form call with a complete `layout` over create-empty-then-patch. Cheaper and atomic.',
252 '- `layout` is an array of rows: [ { "row": [ field, field ] } ]. Single-field rows span full width.',
253 '- Side-by-side fields: put them in the same row with grid:1 / grid:2 (up to grid:3).',
254 '- Multi-step forms: set multi_part:{ enabled:true, parts:[{name:"Step 1"}, ...] } and tag each layout row with part:N. Needs the Multi-Part addon.',
255 '- Conversational (one question at a time): conversational:{ enabled:true, slug, title, description }. Needs the Conversational Forms addon.',
256 '- Multi-part and conversational are MUTUALLY EXCLUSIVE on the same form — never set both.',
257 '',
258 '## Addon-required errors — how to react',
259 '- action:"activate" -> addon is installed but inactive. activate-addon is TWO-STEP: first call WITHOUT confirm returns confirmation_required and does nothing. You MUST ask the user to confirm, then call activate-addon again with confirm:true, then retry the original call. Never set confirm:true unless the user explicitly agreed in this conversation.',
260 '- action:"install_or_upgrade" -> addon is NOT installed. Do NOT call activate-addon (it will fail). Tell the user to install/purchase it from their Everest Forms account or upgrade their plan.',
261 '- If the user declines either, omit the offending field/setting and proceed with the rest.',
262 '- You can pre-check availability with list-addons (look at fully_operational) before building.',
263 '',
264 '## Setting value mappings (do NOT invent values)',
265 '- Save and Continue must be active to use; enable via settings.enable_save_and_continue = "1".',
266 '- Link expiration: settings.save_and_continue_time accepts only "week" | "two_weeks" | "month". Map natural language: ~7 days->"week", ~14 days->"two_weeks", ~30 days/1 month->"month".',
267 '- Entry status values: "publish", "approved", "denied", "pending", "spam", "trash".',
268 '- Form status values (update-form-status): "publish", "draft", "trash".',
269 '',
270 '## Entries',
271 '- create-entry / update-entry-fields accept field references by either meta-key (e.g. "email_3") OR the field human label (e.g. "Email"); the server resolves either.',
272 '- Pass fire_hooks:false on create-entry to skip email notifications/integrations (use for bulk/test imports).',
273 '- For deleting many entries, use bulk-delete-entries with an array of ids rather than many delete-entry calls.',
274 '- delete-entry / bulk-delete-entries default to permanent; pass permanent:false to move to trash instead.',
275 '',
276 '## Editing existing forms',
277 '- update-form deep-merges `settings` (it does not replace them) and appends `fields`/`layout`.',
278 '- To change one existing field without resending everything, use form_fields_patch:{ "<field_id>": { key:value } }. Get the field id from get-form first.',
279 '',
280 '## Safety & UX',
281 '- create / update / delete / activate operations modify the site; the client will prompt the user to approve each one.',
282 '- Use dry_run:true on create-form / update-form to preview the resulting fields + structure without saving.',
283 '- After a successful change, briefly summarize what changed and share the returned edit_url so the user can review it.',
284 );
285
286 $instructions = implode( "\n", $lines );
287
288 /**
289 * Filter the MCP server instructions sent to AI clients on initialize.
290 *
291 * @param string $instructions The default instructions block.
292 */
293 return (string) apply_filters( 'everest_forms_mcp_instructions', $instructions );
294 }
295
296 /**
297 * Execute a tool call.
298 *
299 * @param string $tool_name Tool name (MCP-side, e.g. "list-forms").
300 * @param array $arguments Arguments.
301 * @return array|WP_Error Result envelope for MCP, or error.
302 */
303 protected static function tools_call( $tool_name, $arguments ) {
304 // Accept either the bare name ("list-forms") or the fully-qualified
305 // ability id ("everest-forms/list-forms"). Sanitize and validate
306 // against the actual registry so unknown names fail fast with a clear
307 // error rather than passing through to execute() and producing a
308 // less-specific "ability not found" later on.
309 $bare = (string) $tool_name;
310 $bare = preg_replace( '#^' . preg_quote( EVF_Abilities::NAMESPACE_ID, '#' ) . '/#', '', $bare );
311 $bare = preg_replace( '/[^a-z0-9_-]/', '', strtolower( $bare ) );
312 $ability_id = EVF_Abilities::NAMESPACE_ID . '/' . $bare;
313
314 $known = EVF_Abilities_Registry::instance()->all();
315 if ( ! isset( $known[ $ability_id ] ) ) {
316 return new WP_Error(
317 'evf_unknown_tool',
318 sprintf( 'Unknown tool "%s". Call tools/list to see available tools.', $bare ),
319 array( 'status' => 404 )
320 );
321 }
322
323 $result = EVF_Abilities_Registry::instance()->execute( $ability_id, $arguments );
324
325 if ( is_wp_error( $result ) ) {
326 return $result;
327 }
328
329 // MCP expects "content" array of typed parts. We return a single
330 // JSON text part so clients can both parse it and show it.
331 return array(
332 'content' => array(
333 array(
334 'type' => 'text',
335 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
336 ),
337 ),
338 'isError' => false,
339 );
340 }
341
342 /**
343 * Normalize ability metadata regardless of whether it came from the
344 * Abilities API (object) or our internal registry (array).
345 *
346 * @param mixed $ability Ability handle.
347 * @return array{description: string, input_schema: array}
348 */
349 protected static function ability_meta( $ability ) {
350 $description = '';
351 $input_schema = array( 'type' => 'object' );
352 $label = '';
353
354 if ( is_object( $ability ) ) {
355 if ( method_exists( $ability, 'get_description' ) ) {
356 $description = (string) $ability->get_description();
357 }
358 if ( method_exists( $ability, 'get_label' ) ) {
359 $label = (string) $ability->get_label();
360 }
361 if ( method_exists( $ability, 'get_input_schema' ) ) {
362 $schema = $ability->get_input_schema();
363 if ( ! empty( $schema ) ) {
364 $input_schema = $schema;
365 }
366 }
367 } elseif ( is_array( $ability ) ) {
368 $description = isset( $ability['description'] ) ? (string) $ability['description'] : '';
369 $label = isset( $ability['label'] ) ? (string) $ability['label'] : '';
370 $input_schema = isset( $ability['input_schema'] ) ? $ability['input_schema'] : $input_schema;
371 }
372
373 return array(
374 'label' => $label,
375 'description' => $description,
376 'input_schema' => $input_schema,
377 );
378 }
379
380 /**
381 * Convert "everest-forms/list-forms" into the MCP-side tool name "list-forms".
382 *
383 * Many MCP clients are stricter about tool names than ability ids, so we
384 * keep the namespace implicit and use the bare ability name on the wire.
385 *
386 * @param string $id Ability id.
387 * @return string
388 */
389 protected static function tool_name_from_id( $id ) {
390 $prefix = EVF_Abilities::NAMESPACE_ID . '/';
391 if ( 0 === strpos( (string) $id, $prefix ) ) {
392 return substr( $id, strlen( $prefix ) );
393 }
394 return (string) $id;
395 }
396
397 /**
398 * Build a JSON-RPC 2.0 error response.
399 *
400 * @param mixed $id Message id.
401 * @param int $code Error code.
402 * @param string $message Message.
403 * @param mixed|null $data Optional data.
404 * @return array
405 */
406 protected static function jsonrpc_error( $id, $code, $message, $data = null ) {
407 $err = array(
408 'code' => (int) $code,
409 'message' => (string) $message,
410 );
411 if ( null !== $data ) {
412 $err['data'] = $data;
413 }
414 return array(
415 'jsonrpc' => '2.0',
416 'id' => $id,
417 'error' => $err,
418 );
419 }
420 }
421