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 |