diff --git a/.github/workflows/syntax.yml b/.github/workflows/syntax.yml index a671d6701f..ca55c6481f 100644 --- a/.github/workflows/syntax.yml +++ b/.github/workflows/syntax.yml @@ -32,4 +32,10 @@ jobs: with: php-version: ${{ matrix.php }} - name: Check PHP ${{ matrix.multisite }} syntax - run: find -L . -path ./vendor -prune -o -path ./tests -prune -o -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l + # lib/vendor is pruned as well as vendor: the vendored MCP adapter and + # schema packages use PHP 7.4 syntax, so they do not parse on the 7.0 + # leg of this matrix. That is expected rather than a defect — Formidable + # itself still supports 7.0, and FrmMcpCompat refuses to load the + # adapter below PHP 7.4 (FrmMcpCompat::MIN_PHP_ID), so none of those + # files is ever parsed on a PHP that cannot read them. + run: find -L . -path ./vendor -prune -o -path ./lib/vendor -prune -o -path ./tests -prune -o -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l diff --git a/.gitignore b/.gitignore index 1f6b786789..fddbe8e0b5 100755 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,52 @@ node_modules/* npm-debug.log # Composer -vendor +/vendor composer.lock +/lib/composer.lock +!lib/vendor + +# lib/vendor is committed because the MCP adapter has to ship inside the plugin +# zip, but only the code the plugin loads belongs here. The vendored packages' +# own CI, docs, tooling, and test suites are excluded from the release, so they +# are not tracked either. +# +# Nothing below is autoloadable: composer maps only mcp-adapter/includes, +# php-mcp-schema/src, and jetpack-autoloader/src. The jetpack autoloader is a +# require of the adapter that nothing in it calls, so it is here to keep +# composer install reproducible, never to be loaded. +# +# The depth is explicit on every rule so none of them can reach the plugin's own +# .github, docs, or tests directories. +lib/vendor/*/*/.github/ +lib/vendor/*/*/docs/ +lib/vendor/*/*/generator/ +lib/vendor/*/*/skill/ +lib/vendor/*/*/tests/ + +# Each vendored package's own build tooling and manifests. Composer resolves +# through lib/vendor/composer/, never these, and the zip already drops them. The +# depth keeps the plugin's own composer.json tracked, since that is what +# regenerates lib/vendor. +lib/vendor/*/*/composer.json +lib/vendor/*/*/composer.lock +lib/vendor/*/*/package.json +lib/vendor/*/*/package-lock.json +lib/vendor/*/*/phpstan.neon.dist +lib/vendor/*/*/phpunit.xml.dist +lib/vendor/*/*/.phpcs.xml.dist +lib/vendor/*/*/*.md +lib/vendor/*/*/.editorconfig +lib/vendor/*/*/.gitattributes +lib/vendor/*/*/.npmrc +lib/vendor/*/*/.nvmrc +lib/vendor/*/*/.prettierignore +lib/vendor/*/*/.prettierrc.js +lib/vendor/*/*/.wp-env.json +lib/vendor/*/*/.wp-env.test.json +lib/vendor/*/*/readme.txt +!lib/vendor/*/*/LICENSE.md +!lib/vendor/*/*/LICENSE.txt # PHPUnit .phpunit.result.cache diff --git a/_typos.toml b/_typos.toml index 4f05beda22..33613f9d63 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,3 +1,9 @@ +# Vendored third-party packages ship inside the plugin so the MCP adapter is +# available in the release zip, but their spelling is not ours to correct and +# editing them would be overwritten by the next composer install. +[files] +extend-exclude = ["lib/vendor/**"] + [type.po] extend-glob = ["*.po"] check-file = false diff --git a/classes/controllers/FrmAbilitiesController.php b/classes/controllers/FrmAbilitiesController.php new file mode 100644 index 0000000000..c18a6043ff --- /dev/null +++ b/classes/controllers/FrmAbilitiesController.php @@ -0,0 +1,191 @@ + 'FrmAbilitiesFormsController', + 'fields' => 'FrmAbilitiesFieldsController', + 'entries' => 'FrmAbilitiesEntriesController', + 'styles' => 'FrmAbilitiesStylesController', + 'form-actions' => 'FrmAbilitiesFormActionsController', + ); + + /** + * Filter the ability domains and the classes that own them. + * + * Pro adds entry-writes, styles-pro, stats, and applications. Views adds + * views and view-layouts. A plugin that adds a domain here is stating + * that it registers every ability in it, and the API add-on stops + * registering that domain in response. + * + * @since x.x + * + * @param array $domains Domain names mapped to the controller class that registers them. + */ + return (array) apply_filters( 'frm_ability_domains', $domains ); + } + + /** + * Check whether one ability domain has an owner on this site. + * + * @since x.x + * + * @param string $domain Domain name, such as forms or view-layouts. + * + * @return bool + */ + public static function owns( $domain ) { + if ( ! self::is_active() ) { + return false; + } + + $domains = self::domains(); + + return isset( $domains[ $domain ] ) && class_exists( $domains[ $domain ] ); + } + + /** + * Register the shared ability category. + * + * @since x.x + * @see action hook wp_abilities_api_categories_init + * + * @return void + */ + public static function register_categories() { + if ( ! self::is_active() ) { + return; + } + + // Pro, Views, and the API add-on all register into this category, so + // whichever of them runs first would otherwise register it twice. The + // registry is asked directly because wp_get_ability_category() is a + // getter, not a check: it raises _doing_it_wrong for a category that is + // not there, which is the normal answer here. + if ( self::category_is_registered() ) { + return; + } + + wp_register_ability_category( + self::CATEGORY, + array( + 'label' => __( 'Formidable Forms', 'formidable' ), + 'description' => __( 'Abilities for managing Formidable forms and entries.', 'formidable' ), + ) + ); + } + + /** + * Check whether the shared category has already been registered. + * + * @since x.x + * + * @return bool + */ + private static function category_is_registered() { + if ( ! class_exists( 'WP_Ability_Categories_Registry' ) ) { + return false; + } + + $registry = WP_Ability_Categories_Registry::get_instance(); + + return $registry && $registry->is_registered( self::CATEGORY ); + } + + /** + * Register every ability Formidable itself owns. + * + * @since x.x + * @see action hook wp_abilities_api_init + * + * @return void + */ + public static function register_abilities() { + if ( ! self::is_active() ) { + return; + } + + FrmAbilitiesFormsController::register_abilities(); + FrmAbilitiesFieldsController::register_abilities(); + FrmAbilitiesEntriesController::register_abilities(); + FrmAbilitiesStylesController::register_abilities(); + FrmAbilitiesFormActionsController::register_abilities(); + } +} diff --git a/classes/controllers/FrmAbilitiesEntriesController.php b/classes/controllers/FrmAbilitiesEntriesController.php new file mode 100644 index 0000000000..058d4687a1 --- /dev/null +++ b/classes/controllers/FrmAbilitiesEntriesController.php @@ -0,0 +1,779 @@ + __( 'List Entries', 'formidable' ), + 'description' => __( 'Retrieve a list of Formidable entries (form submissions). Use list-forms to find the form_id.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key to filter entries. Optional, returns entries from all forms if not provided.', 'formidable' ), + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Current page of the collection.', 'formidable' ), + 'default' => 1, + ), + 'page_size' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of items to return per page.', 'formidable' ), + 'default' => 25, + ), + 'order' => array( + 'type' => 'string', + 'description' => __( 'Order of results (asc or desc, case-insensitive).', 'formidable' ), + 'default' => 'asc', + 'enum' => array( 'asc', 'desc', 'ASC', 'DESC' ), + ), + 'order_by' => array( + 'type' => 'string', + 'description' => __( 'Field to order by (id, created_at, etc.).', 'formidable' ), + 'default' => 'id', + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter entries by field values.', 'formidable' ), + ), + 'start_date' => array( + 'type' => 'string', + 'description' => __( 'Start date for filtering entries (YYYY-MM-DD format).', 'formidable' ), + ), + 'end_date' => array( + 'type' => 'string', + 'description' => __( 'End date for filtering entries (YYYY-MM-DD format).', 'formidable' ), + ), + 'is_draft' => array( + 'type' => 'integer', + 'enum' => array( 0, 1 ), + 'description' => __( 'Filter by draft status: 0 returns only submitted entries, 1 returns only drafts. Both are included when omitted.', 'formidable' ), + ), + ), + ), + 'output_schema' => self::get_list_entries_output_schema(), + 'execute_callback' => 'FrmAbilitiesEntriesController::execute_list_entries', + 'permission_callback' => 'FrmAbilitiesEntriesController::can_list_entries', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Build the output schema for the list entries ability. + * + * Kept out of the registrar because one entry carries enough properties + * to bury everything else the registration declares. + * + * @since x.x + * + * @return array + */ + private static function get_list_entries_output_schema() { + return array( + 'type' => 'object', + 'description' => __( + 'Array of entry objects keyed by item_key. Each entry includes id, item_key, form_id, user_id, created_at, and meta (field values).', + 'formidable' + ), + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric entry ID', 'formidable' ), + ), + 'item_key' => array( + 'type' => 'string', + 'description' => __( 'Unique alphanumeric entry key', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this entry belongs to', 'formidable' ), + ), + 'user_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the user who submitted the entry (0 if guest)', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Entry creation date in MySQL format', 'formidable' ), + ), + 'updated_at' => array( + 'type' => 'string', + 'description' => __( 'Entry last update date in MySQL format', 'formidable' ), + ), + 'is_draft' => array( + 'type' => array( 'string', 'boolean' ), + 'description' => __( 'Whether the entry is a draft, returned as "0" or "1"', 'formidable' ), + ), + 'meta' => array( + 'type' => 'object', + 'description' => __( 'Field values keyed by field ID or field_key', 'formidable' ), + 'additionalProperties' => true, + ), + ), + ), + ); + } + + /** + * Register the get entry ability. + * + * @return void + */ + private static function register_get_entry_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/get-entry', + array( + 'label' => __( 'Get Entry', 'formidable' ), + 'description' => __( 'Retrieve a single Formidable entry by ID or key. Returns all field values and entry metadata.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Entry ID or item_key. Required.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Entry object with comprehensive field values and metadata.', 'formidable' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric entry ID', 'formidable' ), + ), + 'item_key' => array( + 'type' => 'string', + 'description' => __( 'Unique alphanumeric entry key', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this entry belongs to', 'formidable' ), + ), + 'user_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the user who submitted the entry (0 if guest)', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Entry creation date in MySQL format', 'formidable' ), + ), + 'updated_at' => array( + 'type' => 'string', + 'description' => __( 'Entry last update date in MySQL format', 'formidable' ), + ), + 'is_draft' => array( + 'type' => array( 'string', 'boolean' ), + 'description' => __( 'Whether the entry is a draft, returned as "0" or "1"', 'formidable' ), + ), + 'meta' => array( + 'type' => 'object', + 'description' => __( 'Field values keyed by field ID or field_key', 'formidable' ), + 'additionalProperties' => true, + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesEntriesController::execute_get_entry', + 'permission_callback' => 'FrmAbilitiesEntriesController::can_get_entry', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the delete entry ability. + * + * @return void + */ + private static function register_delete_entry_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/delete-entry', + array( + 'label' => __( 'Delete Entry', 'formidable' ), + 'description' => __( 'Delete a Formidable entry.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Entry ID or item_key.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Deleted entry object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesEntriesController::execute_delete_entry', + 'permission_callback' => 'FrmAbilitiesEntriesController::can_delete_entry', + 'meta' => FrmAbilitiesHelper::meta( false, true, false ), + ) + ); + } + /** + * List entries, optionally scoped to one form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_list_entries( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $input = FrmAbilitiesHelper::normalize_order( $input ); + $where = array(); + + // Listings include drafts unless is_draft is sent explicitly. + if ( isset( $input['is_draft'] ) ) { + $where['is_draft'] = absint( $input['is_draft'] ); + } + + if ( ! empty( $input['form_id'] ) ) { + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $where['form_id'] = (int) $form->id; + + if ( ! empty( $input['search'] ) && class_exists( 'FrmProEntriesHelper' ) ) { + $search_args = array(); + + if ( isset( $where['is_draft'] ) ) { + $search_args['is_draft'] = $where['is_draft']; + } + + $where['it.id'] = FrmProEntriesHelper::get_search_ids( $input['search'], $form->id, $search_args ); + } + } + + if ( ! empty( $input['start_date'] ) ) { + $where['it.created_at >'] = gmdate( 'Y-m-d H:i:s', strtotime( $input['start_date'] ) ); + } + + if ( ! empty( $input['end_date'] ) ) { + $where['it.created_at <'] = gmdate( 'Y-m-d H:i:s', strtotime( $input['end_date'] ) ); + } + + if ( isset( $where['it.id'] ) && ! $where['it.id'] ) { + // The search matched nothing. Querying on an empty id list would drop + // the condition and return every entry instead. + return array(); + } + + list( $order, $limit ) = FrmAbilitiesHelper::prepare_order_and_limit( $input ); + + $entries = FrmEntry::getAll( $where, $order, $limit, false, false ); + $item_form_id = 0; + $fields = array(); + $data = array(); + + foreach ( $entries as $entry ) { + if ( (int) $item_form_id !== (int) $entry->form_id ) { + $fields = FrmField::get_all_for_form( $entry->form_id, '', 'include' ); + $item_form_id = $entry->form_id; + } + + $entry->meta = FrmEntriesController::show_entry_shortcode( + array( + 'format' => 'array', + 'include_blank' => true, + 'id' => $entry->id, + 'user_info' => false, + 'fields' => $fields, + ) + ); + + $data[ $entry->item_key ] = self::prepare_entry_for_response( $entry ); + } + + return $data; + } + + /** + * Get one entry, with its field values. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_get_entry( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $entry = self::get_entry_with_meta( $input['id'] ); + + return is_wp_error( $entry ) ? $entry : self::prepare_entry_for_response( $entry ); + } + + /** + * Delete an entry. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_delete_entry( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $entry = FrmEntry::getOne( $input['id'] ); + + if ( ! $entry ) { + return self::get_invalid_entry_error(); + } + + // Read the entry before it is gone, so the caller gets back what it deleted. + $entry->meta = array(); + + if ( ! FrmEntry::destroy( $entry->id ) ) { + return self::get_invalid_entry_error(); + } + + return self::prepare_entry_for_response( $entry ); + } + + /** + * Load one entry with its field values attached. + * + * @since x.x + * + * @param int|string $id Entry ID or item_key. + * + * @return object|WP_Error + */ + private static function get_entry_with_meta( $id ) { + $entry = FrmEntry::getOne( $id ); + + if ( ! $entry ) { + return self::get_invalid_entry_error(); + } + + // The Surveys add-on strips a Likert field's row fields out of the entry + // values so its own display can group them. That is right for a rendered + // entry and wrong for one being read as data, where the rows are the value. + $likert_filter = array( 'FrmSurveys\controllers\LikertController', 'remove_row_fields_from_form' ); + $has_likert = class_exists( 'FrmSurveys\controllers\LikertController' ); + + if ( $has_likert ) { + remove_filter( 'frm_entry_values_fields', $likert_filter ); + } + + $entry->meta = FrmEntriesController::show_entry_shortcode( + array( + 'format' => 'array', + 'include_blank' => true, + 'id' => $id, + 'user_info' => false, + 'child_array' => true, + 'date_format' => 'Y-m-d', + ) + ); + + if ( $has_likert ) { + add_filter( 'frm_entry_values_fields', $likert_filter ); + } + + return $entry; + } + + /** + * Re-key item_meta values that use field keys to their numeric field ids. + * + * The input schema documents item_meta as keyed by field id or field key, but + * validation and the save only recognize numeric ids. When a value is present + * under both the id and the key, the id-keyed value wins. + * + * @since x.x + * + * Public because Pro's update-entry ability re-keys the same way before it + * merges the submitted values over the stored ones. + * + * @param array $item_meta Field values keyed by field id or field key. + * @param int|string $form_id The form the fields belong to. + * + * @return void + */ + public static function normalize_item_meta_keys( &$item_meta, $form_id ) { + if ( ! is_array( $item_meta ) || array() === $item_meta ) { + return; + } + + foreach ( FrmField::get_all_for_form( $form_id ) as $field ) { + if ( ! isset( $item_meta[ $field->field_key ] ) ) { + continue; + } + + if ( ! isset( $item_meta[ $field->id ] ) ) { + $item_meta[ $field->id ] = $item_meta[ $field->field_key ]; + } + + // Always consume the key entry. Core resolves leftover field key keys + // when metas are saved, so a duplicate would overwrite the id keyed + // value that is supposed to win. + unset( $item_meta[ $field->field_key ] ); + } + } + + /** + * Put each submitted value into the shape the database stores. + * + * Public because Pro's update-entry ability puts its merged values through + * the same conversions before saving them. + * + * @since x.x + * + * @param array $entry Raw entry input. + * @param array $fields Fields on the form. + * + * @return array + */ + public static function prepare_entry_data( $entry, $fields ) { + $set_meta = ! isset( $entry['item_meta'] ); + $data = array(); + $possible_data = array( + 'id', + 'item_key', + 'name', + 'description', + 'ip', + 'form_id', + 'post_id', + 'user_id', + 'parent_item_id', + 'is_draft', + 'updated_by', + 'created_at', + 'updated_at', + ); + + foreach ( $possible_data as $possible ) { + if ( isset( $entry[ $possible ] ) ) { + $data[ $possible ] = $entry[ $possible ]; + } + } + + $data['item_meta'] = $set_meta ? array() : $entry['item_meta']; + + // The import value conversions below are Pro field behavior. Without Pro + // the values are stored as sent. + $include = class_exists( 'FrmProAppHelper' ); + + foreach ( $fields as $field ) { + if ( $set_meta ) { + if ( isset( $entry[ $field->id ] ) ) { + $data['item_meta'][ $field->id ] = $entry[ $field->id ]; + } elseif ( isset( $entry[ $field->field_key ] ) ) { + $data['item_meta'][ $field->id ] = $entry[ $field->field_key ]; + } + } + + if ( 'divider' === $field->type && FrmField::is_option_true( $field, 'repeat' ) ) { + if ( ! isset( $data['item_meta'][ $field->id ]['form'] ) ) { + $data['item_meta'][ $field->id ]['form'] = $field->field_options['form_select']; + } + + self::normalize_repeater_rows( $data['item_meta'][ $field->id ], $field ); + } + + if ( ! $include || ! isset( $data['item_meta'][ $field->id ] ) ) { + continue; + } + + switch ( $field->type ) { + case 'user_id': + $data['item_meta'][ $field->id ] = FrmAppHelper::get_user_id_param( trim( $data['item_meta'][ $field->id ] ) ); + $data['frm_user_id'] = $data['item_meta'][ $field->id ]; + break; + case 'checkbox': + case 'select': + if ( ! is_array( $data['item_meta'][ $field->id ] ) ) { + self::format_field_value( $field, $data['item_meta'][ $field->id ] ); + } + break; + case 'file': + self::format_file_id( $data['item_meta'][ $field->id ], $field ); + break; + case 'data': + case 'date': + self::format_field_value( $field, $data['item_meta'][ $field->id ] ); + } + }//end foreach + + /** + * Filter the entry data an ability is about to save. + * + * @since x.x + * + * @param array $data Prepared entry data. + * @param array $fields Fields on the form. + */ + return (array) apply_filters( 'frm_abilities_prepare_entry_data', $data, $fields ); + } + + /** + * Put a repeater's rows into the indexed shape the save expects. + * + * Rows arrive keyed however the caller wrote them. Formidable identifies a + * new row by an 'i' prefixed index and lists every row in row_ids, so a row + * under any other key is renumbered into a free index rather than dropped. + * + * @since x.x + * + * @param array $rows The repeater's rows, by reference. + * @param stdClass $field The repeater field. + * + * @return void + */ + private static function normalize_repeater_rows( &$rows, $field ) { + if ( ! is_array( $rows ) ) { + return; + } + + $child_form_id = isset( $field->field_options['form_select'] ) ? (int) $field->field_options['form_select'] : 0; + $child_fields = $child_form_id ? FrmField::get_all_for_form( $child_form_id ) : array(); + $ids_by_key = array(); + + foreach ( $child_fields as $child_field ) { + $ids_by_key[ $child_field->field_key ] = $child_field->id; + } + + $normalized = array(); + $next_index = 0; + + foreach ( $rows as $row_key => $row ) { + if ( ! is_array( $row ) || in_array( $row_key, array( 'form', 'row_ids' ), true ) ) { + // Not a row. form and row_ids are the repeater's own metadata. + continue; + } + + foreach ( $ids_by_key as $child_key => $child_id ) { + if ( ! isset( $row[ $child_key ] ) || isset( $row[ $child_id ] ) ) { + continue; + } + + $row[ $child_id ] = $row[ $child_key ]; + unset( $row[ $child_key ] ); + } + + if ( preg_match( '/^i?\d+$/', (string) $row_key ) ) { + $normalized[ $row_key ] = $row; + continue; + } + + // Skip past any index an untouched key already occupies. + while ( isset( $rows[ $next_index ] ) || isset( $rows[ 'i' . $next_index ] ) ) { + ++$next_index; + } + + $normalized[ 'i' . $next_index ] = $row; + ++$next_index; + }//end foreach + + $leading = array( + 'form' => $rows['form'] ?? $child_form_id, + 'row_ids' => array_keys( $normalized ), + ); + + $rows = $leading + $normalized; + } + + /** + * Run a submitted value through the field's own import conversion. + * + * @since x.x + * + * @param stdClass $field The field the value belongs to. + * @param mixed $value The value, by reference. + * + * @return void + */ + private static function format_field_value( $field, &$value ) { + if ( ! is_callable( 'FrmFieldFactory::get_field_object' ) ) { + return; + } + + $field_object = FrmFieldFactory::get_field_object( $field ); + $value = $field_object->get_import_value( $value, array( 'ids' => array() ) ); + } + + /** + * Turn a submitted file value into the attachment ID a file field stores. + * + * @since x.x + * + * @param mixed $value The value, by reference. + * @param stdClass $field The file field. + * + * @return void + */ + private static function format_file_id( &$value, $field ) { + if ( is_callable( 'FrmProFileImport::import_attachment' ) && is_object( $field ) ) { + $_REQUEST['csv_files'] = 1; + $value = FrmProFileImport::import_attachment( $value, $field ); + } else { + $field_object = FrmFieldFactory::get_field_type( 'file' ); + + // get_file_id() belongs to Pro's file field. Without Pro the type + // resolves to a class that does not have it, and the value is stored + // as sent. + if ( is_callable( array( $field_object, 'get_file_id' ) ) ) { + $value = $field_object->get_file_id( $value ); + } + } + + if ( is_array( $value ) || ! strpos( (string) $value, ',' ) ) { + return; + } + + $ids = array_filter( explode( ',', $value ), 'is_numeric' ); + + if ( $ids && count( $ids ) > 1 ) { + $value = $ids; + } + } + + /** + * Treat the fields nobody can answer over an ability as hidden. + * + * A captcha has no challenge to solve without a browser, and a password + * field's confirmation cannot be typed, so validating either would fail every + * create on a form that has one. + * + * @since x.x + * @see filter hook frm_is_field_hidden + * + * @param bool $hidden Whether the field is treated as hidden. + * @param array|object $field Field data. + * + * @return bool + */ + public static function skip_unanswerable_field( $hidden, $field ) { + return in_array( FrmField::get_field_type( $field ), array( 'captcha', 'password' ), true ) ? true : $hidden; + } + + /** + * Build the response shape for one entry. + * + * @since x.x + * + * @param stdClass $entry The entry to describe. + * + * @return array + */ + public static function prepare_entry_for_response( $entry ) { + return array( + 'id' => (int) $entry->id, + 'item_key' => (string) $entry->item_key, + 'name' => (string) $entry->name, + 'ip' => (string) $entry->ip, + 'meta' => $entry->meta, + 'form_id' => (int) $entry->form_id, + 'post_id' => (int) $entry->post_id, + 'user_id' => (int) $entry->user_id, + 'parent_item_id' => (int) $entry->parent_item_id, + 'is_draft' => (int) $entry->is_draft, + 'updated_by' => (int) $entry->updated_by, + 'created_at' => (string) $entry->created_at, + 'updated_at' => (string) $entry->updated_at, + ); + } + + /** + * Build the error returned when no entry matches the given ID. + * + * @since x.x + * + * @return WP_Error + */ + private static function get_invalid_entry_error() { + return new WP_Error( 'frm_entry_invalid_id', __( 'Nothing was found with that id.', 'formidable' ), array( 'status' => 404 ) ); + } + + /** + * Permission callback for list entries. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_list_entries( $input ) { + return current_user_can( 'frm_view_entries' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for get entry. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_get_entry( $input ) { + return current_user_can( 'frm_view_entries' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for delete entry. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_delete_entry( $input ) { + return current_user_can( 'frm_delete_entries' ) || current_user_can( 'administrator' ); + } +} diff --git a/classes/controllers/FrmAbilitiesFieldsController.php b/classes/controllers/FrmAbilitiesFieldsController.php new file mode 100644 index 0000000000..1b5058ded5 --- /dev/null +++ b/classes/controllers/FrmAbilitiesFieldsController.php @@ -0,0 +1,921 @@ + __( 'List Fields', 'formidable' ), + 'description' => __( + 'Retrieve all fields for a specific form. Returns field id, field_key, name, type, and options. Use the field_key when creating entries to map field values.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'form_id' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key to get fields for. Required. Use list-forms to find available forms.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Array of field objects keyed by field_key. Each field includes comprehensive field properties.', 'formidable' ), + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric field ID', 'formidable' ), + ), + 'field_key' => array( + 'type' => 'string', + 'description' => __( 'Unique field key used for entry submission', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Field label displayed to users', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Field description or help text shown below the field', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Field type (text, textarea, radio, checkbox, dropdown, email, number, date, file, etc.)', 'formidable' ), + ), + 'default_value' => array( + 'type' => 'string', + 'description' => __( 'Default value for the field', 'formidable' ), + ), + 'options' => array( + 'type' => 'array', + 'description' => __( 'Array of choice options for radio, dropdown, or checkbox fields', 'formidable' ), + 'items' => array( 'type' => 'string' ), + ), + 'field_order' => array( + 'type' => 'integer', + 'description' => __( 'Display order of the field (lower numbers appear first)', 'formidable' ), + ), + 'required' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the field must be filled before form submission', 'formidable' ), + ), + 'field_options' => array( + 'type' => 'object', + 'description' => __( 'Additional field options and settings', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this field belongs to', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Field creation date in MySQL format', 'formidable' ), + ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFieldsController::execute_list_fields', + 'permission_callback' => 'FrmAbilitiesFieldsController::can_list_fields', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the create field ability. + * + * @return void + */ + private static function register_create_field_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/create-field', + array( + 'label' => __( 'Create Field', 'formidable' ), + 'description' => __( + 'Add a new field to an existing Formidable form. Use list-forms to find the form, and list-fields to see existing fields.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'form_id', 'type' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key to add the field to. Required.', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( + 'Field type. Required. Common types: text, textarea, radio, checkbox, dropdown, email, number, date, file, hidden, html, user_id, captcha.', + 'formidable' + ), + 'enum' => FrmAbilitiesHelper::get_creatable_field_types(), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Field label. Defaults to field type name.', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Optional field description.', 'formidable' ), + ), + 'required' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the field must be filled before form submission. Defaults to false.', 'formidable' ), + 'default' => false, + ), + 'field_order' => array( + 'type' => 'integer', + 'description' => __( 'Display order of the field. Lower numbers appear first.', 'formidable' ), + ), + 'field_key' => array( + 'type' => 'string', + 'description' => __( 'Unique identifier for the field. Autogenerated if not provided.', 'formidable' ), + ), + 'options' => array( + 'type' => array( 'array', 'object' ), + 'description' => __( + 'Choices for radio, dropdown, or checkbox fields. An array of strings, an array of {"label", "value"} objects, or an object keyed by option key.', + 'formidable' + ), + 'items' => array( + 'type' => array( 'string', 'object' ), + ), + 'additionalProperties' => array( + 'type' => array( 'string', 'object' ), + ), + ), + 'default_value' => array( + 'type' => 'string', + 'description' => __( 'Default value for the field.', 'formidable' ), + ), + 'placeholder' => array( + 'type' => 'string', + 'description' => __( 'Placeholder text shown in empty fields.', 'formidable' ), + ), + 'field_options' => array( + 'type' => 'object', + 'description' => __( + 'Field options, merged over the type defaults: format, minnum, maxnum, step, classes, conditional logic, calculations, and the rest.', + 'formidable' + ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'The newly created field object, including its id and field_key.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFieldsController::execute_create_field', + 'permission_callback' => 'FrmAbilitiesFieldsController::can_create_field', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Register the update field ability. + * + * @return void + */ + private static function register_update_field_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/update-field', + array( + 'label' => __( 'Update Field', 'formidable' ), + 'description' => __( 'Update an existing field on a Formidable form. Use list-fields to find the field id.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key the field belongs to. Optional — derived from the field when omitted.', 'formidable' ), + ), + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Field ID to update. Required. Use list-fields to find it.', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Change the field to this type. The new type default options are applied.', 'formidable' ), + 'enum' => FrmAbilitiesHelper::get_creatable_field_types(), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Field label.', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Field description.', 'formidable' ), + ), + 'required' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the field must be filled before form submission.', 'formidable' ), + ), + 'field_order' => array( + 'type' => 'integer', + 'description' => __( 'Display order of the field. Lower numbers appear first.', 'formidable' ), + ), + 'options' => array( + 'type' => array( 'array', 'object' ), + 'description' => __( + 'Choices for radio, dropdown, or checkbox fields. An array, or an object keyed by option key.', + 'formidable' + ), + ), + 'field_options' => array( + 'type' => 'object', + 'description' => __( + 'Field options, merged into the ones already stored. Send only the keys to change. A key sent empty is cleared.', + 'formidable' + ), + ), + 'default_value' => array( + 'type' => 'string', + 'description' => __( 'Default value for the field.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Update result including the updated field_id.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFieldsController::execute_update_field', + 'permission_callback' => 'FrmAbilitiesFieldsController::can_update_field', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Register the delete field ability. + * + * @return void + */ + private static function register_delete_field_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/delete-field', + array( + 'label' => __( 'Delete Field', 'formidable' ), + 'description' => __( 'Delete a field from a Formidable form. Use list-fields to find the field id.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key the field belongs to. Optional, derived from the field when omitted.', 'formidable' ), + ), + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Field ID to delete. Required. Use list-fields to find it.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Deleted field object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFieldsController::execute_delete_field', + 'permission_callback' => 'FrmAbilitiesFieldsController::can_delete_field', + 'meta' => FrmAbilitiesHelper::meta( false, true, false ), + ) + ); + } + + /** + * List the fields on a form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_list_fields( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $fields = FrmField::get_all_for_form( $form->id, '', 'include' ); + $data = array(); + + foreach ( $fields as $field ) { + $data[ $field->field_key ] = self::prepare_field_for_response( $field ); + } + + return $data; + } + + /** + * Create one field on a form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_create_field( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $field = $input; + unset( $field['form_id'] ); + + $prepared = self::prepare_new_field( $field, $form ); + + if ( is_wp_error( $prepared ) ) { + return FrmAbilitiesHelper::flatten_error( $prepared ); + } + + $prepared['form_id'] = (int) $form->id; + $field_id = FrmField::create( $prepared ); + + if ( ! $field_id ) { + return new WP_Error( 'frm_create_field', __( 'Field creation failed.', 'formidable' ), array( 'status' => 409 ) ); + } + + FrmField::delete_form_transient( $form->id ); + FrmForm::clear_form_cache(); + + return self::prepare_field_for_response( FrmField::getOne( $field_id ) ); + } + + /** + * Update one field. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_update_field( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $field = FrmField::getOne( $input['id'] ); + + if ( ! $field ) { + return self::get_invalid_field_error(); + } + + $field_data = self::normalize_field_type( $input ); + unset( $field_data['id'], $field_data['form_id'] ); + + if ( isset( $field_data['placeholder'] ) && is_scalar( $field_data['placeholder'] ) ) { + // Formidable stores the placeholder inside field_options. FrmField::update() + // only writes real columns, so a top level placeholder is silently dropped. + + $field_data['field_options'] = isset( $field_data['field_options'] ) && is_array( $field_data['field_options'] ) + ? $field_data['field_options'] + : array(); + $field_data['field_options']['placeholder'] = $field_data['placeholder']; + unset( $field_data['placeholder'] ); + } + + $field_data = self::merge_field_options_with_existing( $field, $field_data ); + + if ( ! $field_data ) { + return new WP_Error( 'frm_no_update_data', __( 'No data provided to update.', 'formidable' ), array( 'status' => 400 ) ); + } + + $new_type = ! empty( $field_data['type'] ) ? $field_data['type'] : $field->type; + $field_options = isset( $field_data['field_options'] ) && is_array( $field_data['field_options'] ) ? $field_data['field_options'] : (array) $field->field_options; + $checked = self::check_form_select( $new_type, $field_options ); + + if ( is_wp_error( $checked ) ) { + return $checked; + } + + if ( ! FrmField::update( $field->id, $field_data ) ) { + return new WP_Error( 'frm_field_update_failed', __( 'Field update failed.', 'formidable' ), array( 'status' => 500 ) ); + } + + FrmField::delete_form_transient( $field->form_id ); + FrmForm::clear_form_cache(); + + return self::prepare_field_for_response( FrmField::getOne( $field->id ) ); + } + + /** + * Delete one field. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_delete_field( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $field = FrmField::getOne( $input['id'] ); + + if ( ! $field ) { + return self::get_invalid_field_error(); + } + + // form_id is optional, and is only a guard against deleting a field that + // belongs to a different form than the caller believes it does. + if ( ! empty( $input['form_id'] ) ) { + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + if ( ! self::field_belongs_to_form( $field, $form->id ) ) { + return new WP_Error( + 'frm_field_wrong_form', + __( 'That field does not belong to that form.', 'formidable' ), + array( 'status' => 404 ) + ); + } + } + + // Read the field before it is gone, so the caller gets back what it deleted. + $data = self::prepare_field_for_response( $field ); + $form_id = $field->form_id; + + if ( FrmField::is_repeating_field( $field ) && ! empty( $field->field_options['form_select'] ) ) { + self::destroy_fields_in_repeater( $field ); + } + + if ( ! FrmField::destroy( $field->id ) ) { + return self::get_invalid_field_error(); + } + + FrmField::delete_form_transient( $form_id ); + FrmForm::clear_form_cache(); + + return $data; + } + + /** + * Build the field data for a new field, ready for FrmField::create(). + * + * Public because create-form creates its inline fields through here too, so + * a field created alongside its form goes through the same separate value + * detection, repeater child form creation, and validation as one created on + * its own. + * + * @since x.x + * + * @param array $field Raw field input. + * @param stdClass $form The form the field is created on. + * + * @return array|WP_Error + */ + public static function prepare_new_field( $field, $form ) { + $form_id = (int) $form->id; + $field = self::normalize_field_type( $field ); + + if ( empty( $field['type'] ) ) { + return new WP_Error( 'frm_field_no_type', __( 'A field type is required.', 'formidable' ), array( 'status' => 400 ) ); + } + + $prepared = self::apply_field_input( FrmFieldsHelper::setup_new_vars( $field['type'], $form_id ), $field ); + $prepared['form_id'] = $form_id; + + // The builder merges the raw input before this filter fires. Pro's + // FrmProField::create() relies on that to see repeat and create the + // repeater's child form. Input is applied again after the filter so + // explicit input still wins over filter defaults. + $prepared = apply_filters( 'frm_before_field_created', $prepared ); + $prepared = self::apply_field_input( $prepared, $field ); + $prepared['form_id'] = $form_id; + + $prepared = self::maybe_create_repeat_form( $prepared, $form_id ); + $prepared = self::maybe_position_in_repeater( $prepared, $field, $form ); + $prepared = self::maybe_enable_separate_values( $prepared, $field ); + + if ( empty( $field['field_key'] ) && ! empty( $field['name'] ) ) { + // Same reasoning as the form key: setup_new_vars() seeds a random key + // because the builder inserts a field before it is named, while an + // ability sends the name with the field. FrmField::create() runs this + // through get_unique_key() for length and uniqueness. + $prepared['field_key'] = sanitize_title( $field['name'] ); + } + + $checked = self::check_form_select( $prepared['type'], (array) $prepared['field_options'] ); + + return is_wp_error( $checked ) ? $checked : $prepared; + } + + /** + * Map the field type aliases the schemas accept to the canonical type names. + * + * Without this, an alias like "dropdown" is stored verbatim and the field + * renders no input on the frontend. + * + * @since x.x + * + * @param array $field Raw field input, possibly containing a type key. + * + * @return array + */ + public static function normalize_field_type( $field ) { + if ( empty( $field['type'] ) ) { + return $field; + } + + $aliases = array( + 'dropdown' => 'select', + 'star_rating' => 'star', + 'section' => 'divider', + ); + + if ( isset( $aliases[ $field['type'] ] ) ) { + $field['type'] = $aliases[ $field['type'] ]; + } + + return $field; + } + + /** + * Apply raw field input onto a prepared new field array. + * + * The field_options input is merged over the type's seeded defaults instead + * of replacing them, so a partial options object cannot wipe the default + * validation messages and settings. The top level placeholder param is + * mapped into field_options, where Formidable stores it, because + * FrmField::create() only writes real columns and would drop it. + * + * @since x.x + * + * @param array $prepared Prepared field data from FrmFieldsHelper::setup_new_vars(). + * @param array $field Raw field input. + * + * @return array + */ + public static function apply_field_input( $prepared, $field ) { + foreach ( $field as $option => $value ) { + if ( 'field_options' === $option && is_array( $value ) ) { + $prepared['field_options'] = array_merge( (array) $prepared['field_options'], $value ); + continue; + } + + $prepared[ $option ] = $value; + } + + if ( isset( $field['placeholder'] ) && is_scalar( $field['placeholder'] ) ) { + $prepared['field_options']['placeholder'] = $field['placeholder']; + unset( $prepared['placeholder'] ); + } + + return $prepared; + } + + /** + * Create the child form for a new repeater when Pro is available. + * + * The builder gets this through the frm_before_field_created filter, but Pro + * registers that callback in load_admin_hooks() only, so REST and MCP + * requests never run it. Call the Pro model directly instead of relying on + * the hook context. + * + * @since x.x + * + * @param array $prepared Prepared field data. + * @param int $form_id Parent form ID. + * + * @return array + */ + private static function maybe_create_repeat_form( $prepared, $form_id ) { + $is_new_repeater = 'divider' === $prepared['type'] && ! empty( $prepared['field_options']['repeat'] ) && empty( $prepared['field_options']['form_select'] ); + + if ( $is_new_repeater && is_callable( 'FrmProField::create_repeat_form' ) ) { + $prepared['field_options']['form_select'] = FrmProField::create_repeat_form( + 0, + array( + 'parent_form_id' => $form_id, + 'field_name' => $prepared['name'], + ) + ); + } + + return $prepared; + } + + /** + * Slot a new repeater child field after the last field of its section. + * + * Repeater child fields sort into the parent form's field list purely by + * field_order, so an order computed against the child form alone can land + * before the repeater's own divider and render outside the section in the + * builder. When the caller gives no explicit order, the field goes right + * after the last field of its section and the fields that follow shift down. + * + * @since x.x + * + * @param array $prepared Prepared field data. + * @param array $field Raw field input. + * @param stdClass $form The form the field is created on. + * + * @return array + */ + private static function maybe_position_in_repeater( $prepared, $field, $form ) { + if ( isset( $field['field_order'] ) || empty( $form->parent_form_id ) ) { + return $prepared; + } + + $parent_fields = FrmField::get_all_for_form( $form->parent_form_id, '', 'include' ); + $section_order = 0; + + foreach ( $parent_fields as $parent_field ) { + $form_select = isset( $parent_field->field_options['form_select'] ) ? (int) $parent_field->field_options['form_select'] : 0; + $starts_this_section = 'divider' === $parent_field->type && $form_select === (int) $form->id; + $in_this_section = (int) $parent_field->form_id === (int) $form->id; + + if ( $starts_this_section || $in_this_section ) { + $section_order = max( $section_order, (int) $parent_field->field_order ); + } + } + + if ( ! $section_order ) { + return $prepared; + } + + $prepared['field_order'] = $section_order + 1; + + foreach ( $parent_fields as $parent_field ) { + if ( (int) $parent_field->field_order > $section_order ) { + FrmField::update( $parent_field->id, array( 'field_order' => (int) $parent_field->field_order + 1 ) ); + } + } + + return $prepared; + } + + /** + * Reject a repeater or embedded form field with no usable child form. + * + * Both render their rows from the child form referenced by + * field_options.form_select. Without a valid form there, the field is stored + * but cannot add or remove rows, so the write is rejected instead. + * + * @since x.x + * + * @param string $type Field type being written. + * @param array $field_options Complete field_options for the field. + * + * @return true|WP_Error + */ + private static function check_form_select( $type, $field_options ) { + $needs_child_form = 'form' === $type || ( 'divider' === $type && ! empty( $field_options['repeat'] ) ); + + if ( ! $needs_child_form ) { + return true; + } + + $form_select = isset( $field_options['form_select'] ) ? (int) $field_options['form_select'] : 0; + + if ( $form_select > 0 && FrmForm::getOne( $form_select ) ) { + return true; + } + + return new WP_Error( + 'frm_field_missing_form_select', + __( + 'Repeater and embedded form fields need field_options.form_select set to an existing child form ID. Formidable Pro creates that child form automatically.', + 'formidable' + ), + array( 'status' => 400 ) + ); + } + + /** + * Turn on separate values when the options imply them. + * + * Options passed as label and value objects with differing values imply + * separate values. Without the flag, entry validation compares submissions + * against the labels and rejects the values. + * + * @since x.x + * + * @param array $prepared Prepared field data. + * @param array $field Raw field input. + * + * @return array + */ + private static function maybe_enable_separate_values( $prepared, $field ) { + $explicitly_set = isset( $field['separate_value'] ) || isset( $field['field_options']['separate_value'] ); + + if ( empty( $prepared['options'] ) || ! is_array( $prepared['options'] ) || $explicitly_set ) { + return $prepared; + } + + foreach ( $prepared['options'] as $option ) { + $option = (array) $option; + + if ( isset( $option['label'], $option['value'] ) && $option['label'] !== $option['value'] ) { + $prepared['field_options']['separate_value'] = 1; + break; + } + } + + return $prepared; + } + + /** + * Merge a partial field_options update over the field's stored options. + * + * FrmField::update() serializes exactly what it is given, so without this + * merge a partial field_options object silently wipes every other stored + * setting (conditional logic, calculations, validation messages, and the + * rest). A key sent explicitly set to an empty value still clears it. + * + * @since x.x + * + * @param stdClass $field The stored field object. + * @param array $field_data Field data to update, possibly with a partial field_options array. + * + * @return array + */ + private static function merge_field_options_with_existing( $field, $field_data ) { + if ( isset( $field_data['field_options'] ) && is_array( $field_data['field_options'] ) ) { + $field_data['field_options'] = array_merge( (array) $field->field_options, $field_data['field_options'] ); + } + + return $field_data; + } + + /** + * Check whether a field is on a form, directly or inside one of its repeaters. + * + * @since x.x + * + * @param stdClass $field The field object. + * @param int|string $form_id The form ID. + * + * @return bool + */ + private static function field_belongs_to_form( $field, $form_id ) { + if ( (int) $field->form_id === (int) $form_id ) { + return true; + } + + $field_form = FrmForm::getOne( $field->form_id ); + + return $field_form && (int) $field_form->parent_form_id === (int) $form_id; + } + + /** + * Delete the fields inside a repeater's child form. + * + * @since x.x + * + * @param stdClass $field Repeater field. + * + * @return void + */ + private static function destroy_fields_in_repeater( $field ) { + $repeater_form = FrmForm::getOne( $field->field_options['form_select'] ); + + if ( ! $repeater_form ) { + return; + } + + $field_ids = FrmDb::get_col( 'frm_fields', array( 'form_id' => $repeater_form->id ) ); + + foreach ( $field_ids as $field_id ) { + FrmField::destroy( $field_id ); + } + } + + /** + * Build the response shape for one field. + * + * @since x.x + * + * @param stdClass $field The field to describe. + * + * @return array + */ + public static function prepare_field_for_response( $field ) { + return array( + 'id' => (int) $field->id, + 'field_key' => (string) $field->field_key, + 'name' => (string) $field->name, + 'description' => (string) $field->description, + 'type' => (string) $field->type, + 'default_value' => $field->default_value, + 'options' => $field->options, + 'field_order' => (int) $field->field_order, + 'required' => (int) $field->required, + 'field_options' => $field->field_options, + 'form_id' => (int) $field->form_id, + 'created_at' => (string) $field->created_at, + ); + } + + /** + * Build the error returned when no field matches the given ID. + * + * @since x.x + * + * @return WP_Error + */ + private static function get_invalid_field_error() { + return new WP_Error( 'frm_field_invalid_id', __( 'Invalid field ID.', 'formidable' ), array( 'status' => 404 ) ); + } + + /** + * Permission callback for list fields. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_list_fields( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for create field. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_create_field( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for update field. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_update_field( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for delete field. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_delete_field( $input ) { + return current_user_can( 'frm_delete_forms' ) || current_user_can( 'administrator' ); + } +} diff --git a/classes/controllers/FrmAbilitiesFormActionsController.php b/classes/controllers/FrmAbilitiesFormActionsController.php new file mode 100644 index 0000000000..2805d55343 --- /dev/null +++ b/classes/controllers/FrmAbilitiesFormActionsController.php @@ -0,0 +1,841 @@ + __( 'List Form Actions', 'formidable' ), + 'description' => __( + 'Retrieve the actions on a form. Returns id, type, form_id, post_title, post_status, and post_content. Use list-forms for the form_id.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'form_id' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID to get actions for. Required.', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Filter by action type (e.g., email, quiz, quiz_outcome, api, wppost, register, etc.). Optional.', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Filter by post status. By default both publish and draft actions are listed.', 'formidable' ), + 'enum' => array( 'publish', 'draft' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( + 'Array of form action objects keyed by action ID. Each action includes id, type, form_id, post_title, post_status, post_content, and dates.', + 'formidable' + ), + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric action ID', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Action type (e.g., email, quiz, quiz_outcome, api, wppost, register)', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this action belongs to', 'formidable' ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status (publish or draft)', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings (JSON object)', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Action creation date in MySQL format', 'formidable' ), + ), + 'modified_at' => array( + 'type' => 'string', + 'description' => __( 'Action last modified date in MySQL format', 'formidable' ), + ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormActionsController::execute_list_form_actions', + 'permission_callback' => 'FrmAbilitiesFormActionsController::can_list_form_actions', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the get form action ability. + * + * @return void + */ + private static function register_get_form_action_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/get-form-action', + array( + 'label' => __( 'Get Form Action', 'formidable' ), + 'description' => __( 'Retrieve a single form action by ID.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form action ID. Required.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Form action object with comprehensive properties.', 'formidable' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric action ID', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Action type', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this action belongs to', 'formidable' ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Action creation date', 'formidable' ), + ), + 'modified_at' => array( + 'type' => 'string', + 'description' => __( 'Action last modified date', 'formidable' ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormActionsController::execute_get_form_action', + 'permission_callback' => 'FrmAbilitiesFormActionsController::can_get_form_action', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the create form action ability. + * + * @return void + */ + private static function register_create_form_action_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/create-form-action', + array( + 'label' => __( 'Create Form Action', 'formidable' ), + 'description' => __( + 'Create an action on a form. Use list-forms for the form_id. Types include email, quiz, api, wppost, register, payment, and the marketing integrations.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'form_id', 'type' ), + 'properties' => array( + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID to add the action to. Required.', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( + 'Action type. Required. Common types: email, quiz, quiz_outcome, api, wppost, register, payment, stripe, paypal, mailchimp, zapier, n8n.', + 'formidable' + ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title. Optional, defaults to action type name.', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status. Default is publish.', 'formidable' ), + 'default' => 'publish', + 'enum' => array( 'publish', 'draft' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings (JSON object). Optional, varies by action type.', 'formidable' ), + 'additionalProperties' => true, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Created form action object.', 'formidable' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric action ID', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Action type', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this action belongs to', 'formidable' ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Action creation date', 'formidable' ), + ), + 'modified_at' => array( + 'type' => 'string', + 'description' => __( 'Action last modified date', 'formidable' ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormActionsController::execute_create_form_action', + 'permission_callback' => 'FrmAbilitiesFormActionsController::can_create_form_action', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Register the update form action ability. + * + * @return void + */ + private static function register_update_form_action_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/update-form-action', + array( + 'label' => __( 'Update Form Action', 'formidable' ), + 'description' => __( 'Update an existing form action.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form action ID. Required.', 'formidable' ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title.', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status.', 'formidable' ), + 'enum' => array( 'publish', 'draft' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings (JSON object). Merges with existing settings.', 'formidable' ), + 'additionalProperties' => true, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Updated form action object.', 'formidable' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric action ID', 'formidable' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Action type', 'formidable' ), + ), + 'form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'ID of the form this action belongs to', 'formidable' ), + ), + 'post_title' => array( + 'type' => 'string', + 'description' => __( 'Action title', 'formidable' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'Action status', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Action settings', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Action creation date', 'formidable' ), + ), + 'modified_at' => array( + 'type' => 'string', + 'description' => __( 'Action last modified date', 'formidable' ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormActionsController::execute_update_form_action', + 'permission_callback' => 'FrmAbilitiesFormActionsController::can_update_form_action', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Register the delete form action ability. + * + * @return void + */ + private static function register_delete_form_action_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/delete-form-action', + array( + 'label' => __( 'Delete Form Action', 'formidable' ), + 'description' => __( 'Delete a form action.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form action ID. Required.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Deleted form action object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFormActionsController::execute_delete_form_action', + 'permission_callback' => 'FrmAbilitiesFormActionsController::can_delete_form_action', + 'meta' => FrmAbilitiesHelper::meta( false, true, false ), + ) + ); + } + + /** + * List the actions configured on a form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_list_form_actions( $input ) { + FrmAbilitiesHelper::set_current_user(); + + if ( empty( $input['form_id'] ) ) { + return new WP_Error( 'frm_form_actions_missing_form_id', __( 'A form ID is required.', 'formidable' ), array( 'status' => 400 ) ); + } + + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $status = isset( $input['post_status'] ) ? (string) $input['post_status'] : ''; + $type = $input['type'] ?? ''; + + $args = array( + 'post_type' => FrmFormActionsController::$action_post_type, + // Draft actions are disabled but still configured on the form, so + // list them alongside published ones unless a status filter is set. + 'post_status' => '' !== $status ? $status : array( 'publish', 'draft' ), + 'numberposts' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'menu_order' => (int) $form->id, + ); + + $filter_by_type = $type && 'all' !== $type; + + if ( $filter_by_type ) { + global $frm_vars; + $frm_vars['action_type'] = sanitize_title( $type ); + $args['suppress_filters'] = false; + add_filter( 'posts_where', 'FrmFormActionsController::limit_by_type' ); + } + + $actions = get_posts( $args ); + + if ( $filter_by_type ) { + remove_filter( 'posts_where', 'FrmFormActionsController::limit_by_type' ); + } + + $data = array(); + + foreach ( $actions as $action ) { + $data[ $action->ID ] = self::prepare_action_for_response( $action ); + } + + return $data; + } + + /** + * Get one form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_get_form_action( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $action = self::get_action( $input['id'] ); + + return is_wp_error( $action ) ? $action : self::prepare_action_for_response( self::prepare_action_content( $action ) ); + } + + /** + * Create a form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_create_form_action( $input ) { + FrmAbilitiesHelper::set_current_user(); + + if ( empty( $input['form_id'] ) ) { + return new WP_Error( 'frm_form_actions_missing_form_id', __( 'A form ID is required.', 'formidable' ), array( 'status' => 400 ) ); + } + + if ( empty( $input['type'] ) ) { + return new WP_Error( 'frm_form_actions_missing_type', __( 'An action type is required.', 'formidable' ), array( 'status' => 400 ) ); + } + + $form = FrmAbilitiesHelper::get_form( $input['form_id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $action_control = self::get_action_control( $input['type'] ); + + if ( is_wp_error( $action_control ) ) { + return $action_control; + } + + // Actions are numbered per form, and the number is part of the stored id. + $existing = FrmFormAction::get_action_for_form( $form->id, 'all', array( 'post_status' => 'any' ) ); + $action_number = count( $existing ) + 1; + $action_control->_set( $action_number ); + + $action = $action_control->prepare_new( $form->id ); + + if ( isset( $input['post_content'] ) && is_array( $input['post_content'] ) ) { + $action->post_content = array_merge( (array) $action->post_content, $input['post_content'] ); + } + + if ( isset( $input['post_title'] ) ) { + $action->post_title = sanitize_text_field( $input['post_title'] ); + } + + if ( isset( $input['post_status'] ) ) { + $action->post_status = sanitize_text_field( $input['post_status'] ); + } + + // Run the action type's own update() the same way the admin save path + // does. Add-on actions do real work there: the Quizzes actions insert the + // hidden score field the form needs, and On Submit sanitizes its redirect + // URL. Without this, an action created through an ability is only half + // configured until something updates it. The base implementation returns + // the instance unchanged. + $new_instance = $action_control->update( (array) $action, (array) $action ); + + if ( false === $new_instance ) { + return new WP_Error( 'frm_form_actions_create_failed', __( 'The action update method returned false.', 'formidable' ), array( 'status' => 500 ) ); + } + + $action_id = $action_control->save_settings( $new_instance ); + + if ( is_wp_error( $action_id ) ) { + return FrmAbilitiesHelper::flatten_error( $action_id ); + } + + $saved_action = $action_control->get_single_action( $action_id ); + + if ( ! $saved_action ) { + return new WP_Error( 'frm_form_actions_create_failed', __( 'Failed to create the form action.', 'formidable' ), array( 'status' => 500 ) ); + } + + self::maybe_flag_on_submit_migrated( $saved_action, $form ); + + return self::prepare_action_for_response( $saved_action ); + } + + /** + * Update a form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_update_form_action( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $action = self::get_action( $input['id'] ); + + if ( is_wp_error( $action ) ) { + return $action; + } + + $action_type = sanitize_title( $action->post_excerpt ); + $action_control = self::get_action_control( $action_type ); + + if ( is_wp_error( $action_control ) ) { + return new WP_Error( 'frm_form_actions_invalid_type', __( 'That action type no longer exists.', 'formidable' ), array( 'status' => 400 ) ); + } + + // post_excerpt has to be included: the save path uses wp_insert_post, + // which does not merge with the existing fields, and an action saved + // without its type excerpt no longer matches action queries. + $update_data = array( + 'ID' => $action->ID, + 'post_type' => FrmFormActionsController::$action_post_type, + 'post_excerpt' => $action->post_excerpt, + 'menu_order' => $action->menu_order, + 'post_name' => $action->post_name, + 'post_date' => $action->post_date, + 'post_title' => isset( $input['post_title'] ) ? sanitize_text_field( $input['post_title'] ) : $action->post_title, + 'post_status' => isset( $input['post_status'] ) ? sanitize_text_field( $input['post_status'] ) : $action->post_status, + ); + + $stored_content = (array) FrmAppHelper::maybe_json_decode( $action->post_content ); + + if ( isset( $input['post_content'] ) && is_array( $input['post_content'] ) ) { + // Merge over the stored settings so a partial update leaves the rest + // of the action's configuration alone. + $update_data['post_content'] = array_merge( $stored_content, $input['post_content'] ); + } else { + $update_data['post_content'] = $stored_content; + } + + $action->post_content = $stored_content; + $new_instance = $action_control->update( $update_data, (array) $action ); + + if ( false === $new_instance ) { + return new WP_Error( 'frm_form_actions_update_failed', __( 'The action update method returned false.', 'formidable' ), array( 'status' => 500 ) ); + } + + $action_id = $action_control->save_settings( $new_instance ); + + if ( is_wp_error( $action_id ) ) { + return FrmAbilitiesHelper::flatten_error( $action_id ); + } + + $saved_action = $action_control->get_single_action( $action_id ); + + if ( ! $saved_action ) { + return new WP_Error( 'frm_form_actions_update_failed', __( 'Failed to update the form action.', 'formidable' ), array( 'status' => 500 ) ); + } + + return self::prepare_action_for_response( $saved_action ); + } + + /** + * Delete a form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_delete_form_action( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $action = self::get_action( $input['id'] ); + + if ( is_wp_error( $action ) ) { + return $action; + } + + // Read the action before it is gone, so the caller gets back what it deleted. + + if ( ! wp_delete_post( $action->ID, true ) ) { + return new WP_Error( 'frm_form_actions_delete_failed', __( 'Failed to delete the form action.', 'formidable' ), array( 'status' => 500 ) ); + } + + $data = self::prepare_action_for_response( self::prepare_action_content( $action ) ); + + FrmFormAction::clear_cache(); + + return $data; + } + + /** + * Load one form action post. + * + * @since x.x + * + * @param int|string $id Form action ID. + * + * @return WP_Error|WP_Post + */ + private static function get_action( $id ) { + $action = get_post( absint( $id ) ); + + if ( ! $action || FrmFormActionsController::$action_post_type !== $action->post_type ) { + return new WP_Error( 'frm_form_action_invalid_id', __( 'Invalid form action ID.', 'formidable' ), array( 'status' => 404 ) ); + } + + return $action; + } + + /** + * Resolve one action type to the control object that saves it. + * + * FrmFormActionsController::get_form_actions() answers with the full array of + * registered controls when the type does not match a registered id_base, so + * the result is narrowed to one control here rather than used as given. + * + * @since x.x + * + * @param string $type The action type, such as email or on_submit. + * + * @return FrmFormAction|WP_Error The action control, or an error when the type is not registered. + */ + private static function get_action_control( $type ) { + $type = sanitize_title( $type ); + $action_control = FrmFormActionsController::get_form_actions( $type ); + + if ( is_array( $action_control ) ) { + $action_control = $action_control[ $type ] ?? null; + } + + if ( ! $action_control instanceof FrmFormAction ) { + return new WP_Error( 'frm_form_actions_invalid_type', __( 'Invalid action type.', 'formidable' ), array( 'status' => 400 ) ); + } + + return $action_control; + } + + /** + * Let the action type expand its own stored settings before they are read. + * + * @since x.x + * + * @param WP_Post $action The stored action post. + * + * @return object + */ + private static function prepare_action_content( $action ) { + $action_control = self::get_action_control( $action->post_excerpt ); + + if ( ! is_wp_error( $action_control ) ) { + return $action_control->prepare_action( $action ); + } + + // The add-on that registered this action type is no longer active, so + // there is nothing to expand the settings with. Hand back what is stored. + $action->post_content = (array) FrmAppHelper::maybe_json_decode( $action->post_content ); + + return $action; + } + + /** + * Mark a form as migrated when an On Submit action is saved to it. + * + * On Submit actions only run when the form has the on_submit_migrated option + * set. Core sets it during its own settings migration, so a confirmation + * action created through an ability would otherwise never fire. + * + * @since x.x + * + * @param object $saved_action The saved action post object. + * @param stdClass $form The form the action belongs to. + * + * @return void + */ + private static function maybe_flag_on_submit_migrated( $saved_action, $form ) { + $action_type = $saved_action->post_excerpt ?? ''; + + if ( 'on_submit' !== $action_type || ! class_exists( 'FrmOnSubmitHelper' ) ) { + return; + } + + if ( FrmOnSubmitHelper::form_has_migrated( $form ) ) { + return; + } + + if ( ! is_array( $form->options ) ) { + $form->options = array(); + } + + $form->options['on_submit_migrated'] = 1; + + FrmForm::update( $form->id, array( 'options' => $form->options ) ); + } + + /** + * Build the response shape for one form action. + * + * @since x.x + * + * @param object $action The action to describe. + * + * @return array + */ + public static function prepare_action_for_response( $action ) { + $post_content = $action->post_content; + + if ( is_string( $post_content ) ) { + $post_content = json_decode( $post_content, true ); + } + + return array( + 'id' => (int) $action->ID, + 'type' => sanitize_title( $action->post_excerpt ), + 'form_id' => (int) $action->menu_order, + 'post_title' => (string) $action->post_title, + 'post_status' => (string) $action->post_status, + 'post_content' => $post_content, + 'created_at' => (string) $action->post_date, + 'modified_at' => (string) $action->post_modified, + ); + } + + /** + * Permission callback for list form actions. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_list_form_actions( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for get form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_get_form_action( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for create form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_create_form_action( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for update form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_update_form_action( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for delete form action. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_delete_form_action( $input ) { + return current_user_can( 'frm_delete_forms' ) || current_user_can( 'administrator' ); + } +} diff --git a/classes/controllers/FrmAbilitiesFormsController.php b/classes/controllers/FrmAbilitiesFormsController.php new file mode 100644 index 0000000000..31f094fc45 --- /dev/null +++ b/classes/controllers/FrmAbilitiesFormsController.php @@ -0,0 +1,828 @@ + __( 'List Forms', 'formidable' ), + 'description' => __( + 'Retrieve all Formidable forms. Returns id, form_key, name, and description. Use the form_key or id for create-entry, list-fields, or get-form.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Current page of the collection.', 'formidable' ), + 'default' => 1, + ), + 'page_size' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of items to return per page.', 'formidable' ), + 'default' => 20, + ), + 'order' => array( + 'type' => 'string', + 'description' => __( 'Order of results (asc or desc, case-insensitive).', 'formidable' ), + 'default' => 'asc', + 'enum' => array( 'asc', 'desc', 'ASC', 'DESC' ), + ), + 'order_by' => array( + 'type' => 'string', + 'description' => __( 'Field to order by.', 'formidable' ), + 'default' => 'created_at', + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter forms.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Object of form objects keyed by form_key. Each value is a form summary.', 'formidable' ), + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric form ID', 'formidable' ), + ), + 'form_key' => array( + 'type' => 'string', + 'description' => __( 'Unique alphanumeric form key used for API calls', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Form name', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Form description for internal reference', 'formidable' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'Form status, published or draft. Trashed forms are not listed.', 'formidable' ), + 'enum' => array( 'published', 'draft' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Form creation date in MySQL format', 'formidable' ), + ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormsController::execute_list_forms', + 'permission_callback' => 'FrmAbilitiesFormsController::can_list_forms', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the get form ability. + * + * @since x.x + * + * @return void + */ + private static function register_get_form_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/get-form', + array( + 'label' => __( 'Get Form', 'formidable' ), + 'description' => __( 'Retrieve a single Formidable form by ID or key.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key.', 'formidable' ), + ), + 'return' => array( + 'type' => 'string', + 'description' => __( 'Return format: "array" or "html".', 'formidable' ), + 'default' => 'array', + 'enum' => array( 'array', 'html' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Form object or rendered HTML.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFormsController::execute_get_form', + 'permission_callback' => 'FrmAbilitiesFormsController::can_get_form', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the create form ability. + * + * @since x.x + * + * @return void + */ + private static function register_create_form_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/create-form', + array( + 'label' => __( 'Create Form', 'formidable' ), + 'description' => __( + 'Create a new Formidable form with optional fields. Use list-forms to get existing forms, and list-fields to see fields on a form.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => self::get_create_form_input_schema(), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( + 'Created form object with comprehensive properties. Use the form_key or id for subsequent operations like creating entries or listing fields.', + 'formidable' + ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric form ID', 'formidable' ), + ), + 'form_key' => array( + 'type' => 'string', + 'description' => __( 'Unique alphanumeric form key used for API calls', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Form name', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Form description', 'formidable' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'Form status', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Form creation date', 'formidable' ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesFormsController::execute_create_form', + 'permission_callback' => 'FrmAbilitiesFormsController::can_create_form', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Build the input schema for the create form ability. + * + * Kept out of the registrar because the inline fields array makes it long + * enough on its own to bury everything else the registration declares. + * + * @since x.x + * + * @return array + */ + private static function get_create_form_input_schema() { + return array( + 'type' => 'object', + 'required' => array( 'name' ), + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => __( 'Form name. Required.', 'formidable' ), + 'minLength' => 1, + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Form description for internal reference.', 'formidable' ), + ), + 'form_key' => array( + 'type' => 'string', + 'description' => __( + 'Unique identifier, usable in shortcodes in place of the ID. Derived from the form name when omitted, with a numeric suffix if that key is taken.', + 'formidable' + ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'Form status. Default is "published".', 'formidable' ), + 'default' => 'published', + 'enum' => array( 'published', 'draft' ), + ), + 'logged_in' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the form requires users to be logged in to view. Default is false.', 'formidable' ), + 'default' => false, + ), + 'is_template' => array( + 'type' => 'boolean', + 'description' => __( 'Whether this form is a template. Default is false.', 'formidable' ), + 'default' => false, + ), + 'parent_form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Parent form ID if this is a child form (e.g., for repeaters). Default is 0.', 'formidable' ), + 'default' => 0, + ), + 'editable' => array( + 'type' => 'boolean', + 'description' => __( 'Whether entries can be edited after submission. Default is false.', 'formidable' ), + 'default' => false, + ), + 'options' => array( + 'type' => 'object', + 'description' => __( + 'Form options such as submit_value, success_action, and success_msg, applied as the form is created. Anything left out takes its default.', + 'formidable' + ), + ), + 'fields' => array( + 'type' => 'array', + 'description' => __( + 'Array of field objects to create in the form. Each field represents a form input like text, dropdown, checkbox, etc.', + 'formidable' + ), + 'items' => self::get_inline_field_schema(), + ), + ), + ); + } + + /** + * Build the schema for one field sent inline with a new form. + * + * The same shape create-field accepts, minus form_id, which the form + * being created supplies. + * + * @since x.x + * + * @return array + */ + private static function get_inline_field_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'description' => __( + 'Field type. Required. Common types: text, textarea, radio, checkbox, dropdown, email, number, date, file, hidden, html, user_id.', + 'formidable' + ), + 'enum' => FrmAbilitiesHelper::get_creatable_field_types(), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Field label. Defaults to field type name.', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'Optional field description.', 'formidable' ), + ), + 'required' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the field must be filled before form submission. Defaults to false.', 'formidable' ), + 'default' => false, + ), + 'field_order' => array( + 'type' => 'integer', + 'description' => __( 'Display order of the field. Lower numbers appear first. Defaults to auto-increment.', 'formidable' ), + ), + 'field_key' => array( + 'type' => 'string', + 'description' => __( 'Unique identifier for the field. Autogenerated if not provided. Used for referencing in templates.', 'formidable' ), + ), + 'options' => array( + 'type' => array( 'array', 'object' ), + 'description' => __( + 'Choices for a radio, dropdown, or checkbox field. Strings, {"label", "value"} objects, or an object keyed by option key.', + 'formidable' + ), + 'items' => array( + 'type' => array( 'string', 'object' ), + ), + 'additionalProperties' => array( + 'type' => array( 'string', 'object' ), + ), + ), + 'default_value' => array( + 'type' => 'string', + 'description' => __( 'Default value for the field.', 'formidable' ), + ), + 'placeholder' => array( + 'type' => 'string', + 'description' => __( 'Placeholder text shown in empty fields.', 'formidable' ), + ), + 'field_options' => array( + 'type' => 'object', + 'description' => __( 'Additional field options and settings, merged over the type defaults.', 'formidable' ), + ), + ), + ); + } + + /** + * Register the update form ability. + * + * @since x.x + * + * @return void + */ + private static function register_update_form_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/update-form', + array( + 'label' => __( 'Update Form', 'formidable' ), + 'description' => __( + 'Update an existing Formidable form: name, description, status, options, or parent_form_id (for repeater child forms).', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'The form ID to update. Required.', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The new form name.', 'formidable' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'The new form description.', 'formidable' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The form status. Use trash to move the form to the trash.', 'formidable' ), + 'enum' => array( 'published', 'draft', 'trash' ), + ), + 'options' => array( + 'type' => 'object', + 'description' => __( 'Form options to merge (submit_value, success_msg, etc.).', 'formidable' ), + ), + 'parent_form_id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Parent form ID for repeater child forms. Set to 0 to detach.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Updated form object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFormsController::execute_update_form', + 'permission_callback' => 'FrmAbilitiesFormsController::can_update_form', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * Register the delete form ability. + * + * @since x.x + * + * @return void + */ + private static function register_delete_form_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/delete-form', + array( + 'label' => __( 'Delete Form', 'formidable' ), + 'description' => __( 'Delete a Formidable form.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Form ID or form_key.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Deleted form object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesFormsController::execute_delete_form', + 'permission_callback' => 'FrmAbilitiesFormsController::can_delete_form', + 'meta' => FrmAbilitiesHelper::meta( false, true, false ), + ) + ); + } + + /** + * List the forms on the site. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array + */ + public static function execute_list_forms( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $input = FrmAbilitiesHelper::normalize_order( $input ); + + $where = array( + 'is_template' => 0, + 'status' => array( null, '', 'published', 'draft' ), + ); + + if ( ! empty( $input['search'] ) ) { + $where[] = array( + 'name like' => $input['search'], + 'description like' => $input['search'], + 'or' => 1, + ); + } + + list( $order, $limit ) = FrmAbilitiesHelper::prepare_order_and_limit( $input ); + + $forms = FrmForm::getAll( $where, $order, $limit ); + + if ( is_object( $forms ) ) { + $forms = array( $forms ); + } + + $data = array(); + + foreach ( $forms as $form ) { + // Cast the values to match the declared output schema (integer ID, + // non-null strings). A null or empty status is a legacy value that + // means published. + $data[ $form->form_key ] = array( + 'id' => (int) $form->id, + 'form_key' => (string) $form->form_key, + 'name' => (string) $form->name, + 'description' => (string) $form->description, + 'status' => $form->status ? (string) $form->status : 'published', + 'created_at' => (string) $form->created_at, + ); + } + + return $data; + } + + /** + * Get one form, as data or as rendered HTML. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_get_form( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmAbilitiesHelper::get_form( $input['id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + if ( isset( $input['return'] ) && 'html' === $input['return'] ) { + return array( + 'renderedHtml' => FrmFormsController::get_form_shortcode( array( 'id' => $form->id ) ), + ); + } + + return self::prepare_form_for_response( $form ); + } + + /** + * Create a form, and any fields sent with it. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_create_form( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmFormsHelper::setup_new_vars( array() ); + + foreach ( array( 'name', 'description', 'form_key', 'status', 'logged_in', 'is_template', 'parent_form_id', 'editable' ) as $key ) { + if ( isset( $input[ $key ] ) ) { + $form[ $key ] = $input[ $key ]; + } + } + + if ( isset( $input['options'] ) && is_array( $input['options'] ) ) { + // FrmForm::create() reads every form option out of $values['options'], + // but setup_new_vars() returns them flattened onto the top level, so + // submitted options never reach the new form and each one silently + // falls back to its default. Nest them again so they are applied at + // creation. Options left out still pick up their defaults in + // FrmFormsHelper::fill_form_options(). + $stored = isset( $form['options'] ) && is_array( $form['options'] ) ? $form['options'] : array(); + $form['options'] = array_merge( $stored, self::sanitize_option_values( $input['options'] ) ); + } + + if ( empty( $input['form_key'] ) && ! empty( $input['name'] ) ) { + // The builder derives the form key from the form name whenever the + // name is set, in FrmFormsController::update_form_name(). It cannot + // happen in setup_new_vars(), which runs before the form is named and + // so falls back to a random key. Here the name arrives with the + // request, so the readable key can be built right away. FrmForm::create() + // still runs this through get_unique_key(), which handles length, + // reserved words, and collision suffixes. + $form['form_key'] = sanitize_title( $input['name'] ); + } + + $form_id = FrmForm::create( $form ); + + if ( ! $form_id ) { + return new WP_Error( 'frm_create_form', __( 'Form creation failed.', 'formidable' ), array( 'status' => 409 ) ); + } + + $new_form = FrmForm::getOne( $form_id ); + + if ( ! empty( $input['fields'] ) && is_array( $input['fields'] ) ) { + foreach ( $input['fields'] as $field ) { + // Share create-field's pipeline so inline fields get the same + // separate-value detection, repeater child form, and validation. + $prepared = FrmAbilitiesFieldsController::prepare_new_field( $field, $new_form ); + + if ( is_wp_error( $prepared ) ) { + return FrmAbilitiesHelper::flatten_error( $prepared ); + } + + $prepared['form_id'] = $form_id; + + FrmField::create( $prepared ); + unset( $prepared, $field ); + } + } + + FrmField::delete_form_transient( $form_id ); + FrmForm::clear_form_cache(); + + return self::prepare_form_for_response( FrmForm::getOne( $form_id ) ); + } + + /** + * Update a form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_update_form( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmAbilitiesHelper::get_form( $input['id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + $values = array(); + + if ( isset( $input['name'] ) ) { + $values['name'] = sanitize_text_field( $input['name'] ); + } + + if ( isset( $input['description'] ) ) { + $values['description'] = sanitize_textarea_field( $input['description'] ); + } + + if ( isset( $input['status'] ) ) { + $status = sanitize_text_field( $input['status'] ); + + if ( 'publish' === $status ) { + // Accept the WP post-status spelling, but store Formidable's. + $status = 'published'; + } + + if ( ! in_array( $status, array( 'published', 'draft', 'trash' ), true ) ) { + return new WP_Error( 'frm_form_invalid_status', __( 'Invalid form status. Use published, draft, or trash.', 'formidable' ), array( 'status' => 400 ) ); + } + + $values['status'] = $status; + } + + if ( isset( $input['options'] ) && is_array( $input['options'] ) ) { + // Merge over the stored options: FrmForm::update() rebuilds the + // options column from the submitted array alone, so a partial update + // would reset every omitted option to its default and zero + // custom_style, dropping the form's assigned style. + $values['options'] = array_merge( (array) $form->options, self::sanitize_option_values( $input['options'] ) ); + } + + if ( isset( $input['parent_form_id'] ) ) { + $values['parent_form_id'] = absint( $input['parent_form_id'] ); + } + + if ( ! $values ) { + return new WP_Error( 'frm_no_update_data', __( 'No data provided to update.', 'formidable' ), array( 'status' => 400 ) ); + } + + $values['id'] = $form->id; + $result = FrmForm::update( $form->id, $values ); + + if ( ! $result ) { + return new WP_Error( 'frm_form_update_failed', __( 'Form update failed.', 'formidable' ), array( 'status' => 500 ) ); + } + + return self::prepare_form_for_response( FrmForm::getOne( $form->id ) ); + } + + /** + * Delete a form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_delete_form( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $form = FrmAbilitiesHelper::get_form( $input['id'] ); + + if ( is_wp_error( $form ) ) { + return $form; + } + + // Read the form before it is gone, so the caller gets back what it deleted. + + if ( ! FrmForm::destroy( $form->id ) ) { + return new WP_Error( 'frm_form_delete_failed', __( 'Form deletion failed.', 'formidable' ), array( 'status' => 500 ) ); + } + + return self::prepare_form_for_response( $form ); + } + + /** + * Build the response shape for one form. + * + * @since x.x + * + * @param stdClass $form The form to describe. + * + * @return array + */ + public static function prepare_form_for_response( $form ) { + return array( + 'id' => (int) $form->id, + 'form_key' => (string) $form->form_key, + 'name' => (string) $form->name, + 'description' => (string) $form->description, + 'status' => $form->status ? (string) $form->status : 'published', + 'parent_form_id' => (int) $form->parent_form_id, + 'logged_in' => (int) $form->logged_in, + 'is_template' => (int) $form->is_template, + 'options' => (array) $form->options, + 'editable' => (int) $form->editable, + 'created_at' => (string) $form->created_at, + 'link' => FrmFormsHelper::get_direct_link( $form->form_key, $form ), + ); + } + + /** + * Recursively sanitize option values, following core Formidable semantics. + * + * Strings are left unfiltered when FrmAppHelper::allow_unfiltered_html() + * allows it (user capability plus DISALLOW_UNFILTERED_HTML and the + * frm_disallow_unfiltered_html filter), and go through Formidable's kses + * otherwise. Other scalars pass through, and anything that is not scalar + * drops to an empty string. + * + * @since x.x + * + * @param array $values Option values to sanitize. + * + * @return array + */ + private static function sanitize_option_values( $values ) { + $allow_unfiltered = FrmAppHelper::allow_unfiltered_html(); + + foreach ( $values as $key => $value ) { + if ( is_array( $value ) ) { + $values[ $key ] = self::sanitize_option_values( $value ); + } elseif ( is_string( $value ) ) { + $values[ $key ] = $allow_unfiltered ? $value : FrmAppHelper::kses( $value, 'all' ); + } elseif ( ! is_scalar( $value ) ) { + $values[ $key ] = ''; + } + } + + return $values; + } + + /** + * Permission callback for list forms. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_list_forms( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for get form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_get_form( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for create form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_create_form( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for update form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_update_form( $input ) { + return current_user_can( 'frm_edit_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for delete form. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_delete_form( $input ) { + return current_user_can( 'frm_delete_forms' ) || current_user_can( 'administrator' ); + } +} diff --git a/classes/controllers/FrmAbilitiesStylesController.php b/classes/controllers/FrmAbilitiesStylesController.php new file mode 100644 index 0000000000..a986ec1336 --- /dev/null +++ b/classes/controllers/FrmAbilitiesStylesController.php @@ -0,0 +1,460 @@ + __( 'List Styles', 'formidable' ), + 'description' => __( + 'Retrieve all Formidable styles. Returns id, post_name, name, and settings. Use the id or post_name for get-style or update-style.', + 'formidable' + ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Current page of the collection.', 'formidable' ), + 'default' => 1, + ), + 'page_size' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of items to return per page.', 'formidable' ), + 'default' => 20, + ), + 'order' => array( + 'type' => 'string', + 'description' => __( 'Order of results (asc or desc, case-insensitive).', 'formidable' ), + 'default' => 'asc', + 'enum' => array( 'asc', 'desc', 'ASC', 'DESC' ), + ), + 'order_by' => array( + 'type' => 'string', + 'description' => __( 'Field to order by.', 'formidable' ), + 'default' => 'title', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Array of style objects keyed by style ID. Each style includes comprehensive style properties.', 'formidable' ), + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Numeric style ID', 'formidable' ), + ), + 'post_name' => array( + 'type' => 'string', + 'description' => __( 'CSS scope slug for the style', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Style name', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Style settings array containing colors, fonts, spacing, etc.', 'formidable' ), + ), + 'menu_order' => array( + 'type' => 'integer', + 'description' => __( 'Whether this is the default style (1) or not (0)', 'formidable' ), + ), + 'created_at' => array( + 'type' => 'string', + 'description' => __( 'Style creation date in MySQL format', 'formidable' ), + ), + ), + ), + ), + 'execute_callback' => 'FrmAbilitiesStylesController::execute_list_styles', + 'permission_callback' => 'FrmAbilitiesStylesController::can_list_styles', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the get style ability. + * + * @return void + */ + private static function register_get_style_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/get-style', + array( + 'label' => __( 'Get Style', 'formidable' ), + 'description' => __( 'Retrieve a single Formidable style by ID or post_name.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Style ID or post_name. Use "default" to get the default style.', 'formidable' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Style object with comprehensive properties.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesStylesController::execute_get_style', + 'permission_callback' => 'FrmAbilitiesStylesController::can_get_style', + 'meta' => FrmAbilitiesHelper::meta( true, false, true ), + ) + ); + } + + /** + * Register the update style ability. + * + * @return void + */ + private static function register_update_style_ability() { + FrmAbilitiesHelper::register( + 'formidable-forms/update-style', + array( + 'label' => __( 'Update Style', 'formidable' ), + 'description' => __( 'Update an existing Formidable style.', 'formidable' ), + 'category' => FrmAbilitiesController::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Style ID or post_name.', 'formidable' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Style name.', 'formidable' ), + ), + 'post_content' => array( + 'type' => 'object', + 'description' => __( 'Style settings array containing colors, fonts, spacing, etc.', 'formidable' ), + 'additionalProperties' => true, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'Updated style object.', 'formidable' ), + ), + 'execute_callback' => 'FrmAbilitiesStylesController::execute_update_style', + 'permission_callback' => 'FrmAbilitiesStylesController::can_update_style', + 'meta' => FrmAbilitiesHelper::meta( false, false, false ), + ) + ); + } + + /** + * List the styles on the site. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array + */ + public static function execute_list_styles( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $input = FrmAbilitiesHelper::normalize_order( $input ); + $frm_style = new FrmStyle(); + + $order_by = ! empty( $input['order_by'] ) ? $input['order_by'] : 'post_title'; + $order = ! empty( $input['order'] ) ? $input['order'] : 'ASC'; + $page_size = ! empty( $input['page_size'] ) ? absint( $input['page_size'] ) : 20; + + $styles = $frm_style->get_all( $order_by, $order, $page_size ); + $data = array(); + + foreach ( $styles as $style ) { + $data[ $style->ID ] = self::prepare_style_for_response( $style ); + } + + return $data; + } + + /** + * Get one style. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_get_style( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $style = self::get_style( $input['id'] ); + + return is_wp_error( $style ) ? $style : self::prepare_style_for_response( $style ); + } + + /** + * Update a style. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array|WP_Error + */ + public static function execute_update_style( $input ) { + FrmAbilitiesHelper::set_current_user(); + + $id = sanitize_text_field( $input['id'] ); + $style = 'default' === $id ? self::get_style( 'default' ) : get_post( $id ); + + if ( is_wp_error( $style ) ) { + return $style; + } + + if ( ! $style || FrmStylesController::$post_type !== $style->post_type ) { + return self::get_invalid_style_error(); + } + + $style_array = array( + 'ID' => $style->ID, + 'post_type' => FrmStylesController::$post_type, + 'post_title' => $style->post_title, + 'post_name' => $style->post_name, + 'post_content' => FrmAppHelper::maybe_json_decode( $style->post_content ), + 'menu_order' => $style->menu_order, + 'post_status' => $style->post_status, + ); + + if ( isset( $input['name'] ) ) { + $style_array['post_title'] = sanitize_text_field( $input['name'] ); + } + + if ( isset( $input['post_content'] ) && is_array( $input['post_content'] ) ) { + // Merge over the stored settings so a partial update leaves every + // other setting alone. + $existing = is_array( $style_array['post_content'] ) ? $style_array['post_content'] : array(); + $style_array['post_content'] = array_merge( $existing, self::strip_hex_prefix( $input['post_content'] ) ); + } + + $frm_style = new FrmStyle( $style->ID ); + $result = $frm_style->save( $style_array ); + + if ( ! $result || is_wp_error( $result ) ) { + return new WP_Error( 'frm_update_style', __( 'Style update failed.', 'formidable' ), array( 'status' => 409 ) ); + } + + // FrmStyle->save() does not respect post_type, so set it explicitly. + wp_update_post( + array( + 'ID' => $style->ID, + 'post_type' => FrmStylesController::$post_type, + ) + ); + + // Regenerate the stylesheet and refresh the transients. + $frm_style->save_settings(); + + $updated = self::get_style( $style->ID ); + + return is_wp_error( $updated ) ? $updated : self::prepare_style_for_response( $updated ); + } + + /** + * Load one style, by ID or as the default. + * + * Public because Pro's delete-style and assign-style-to-form abilities need + * the same lookup, with the same post type guard. + * + * @since x.x + * + * @param int|string $id Style ID, or 'default' for the default style. + * + * @return stdClass|WP_Error|WP_Post + */ + public static function get_style( $id ) { + $id = sanitize_text_field( $id ); + $frm_style = 'default' === $id ? new FrmStyle( 'default' ) : new FrmStyle( $id ); + $style = $frm_style->get_one(); + + if ( ( ! $style || ! self::is_style_post( $style ) ) && 'default' !== $id ) { + // FrmStyle::get_one() resolves only a numeric ID, but the input + // schema documents post_name as an accepted id too. + $style = self::get_style_by_post_name( $id ); + } + + if ( ! $style || ! self::is_style_post( $style ) ) { + return self::get_invalid_style_error(); + } + + return $style; + } + + /** + * Load one style by its post_name. + * + * @since x.x + * + * @param string $post_name The style's post_name. + * + * @return WP_Post|null + */ + private static function get_style_by_post_name( $post_name ) { + $styles = get_posts( + array( + 'name' => $post_name, + 'post_type' => FrmStylesController::$post_type, + 'post_status' => 'publish', + 'numberposts' => 1, + ) + ); + + $style = $styles ? reset( $styles ) : null; + + return $style instanceof WP_Post ? $style : null; + } + + /** + * Check that a post loaded by ID is really a style. + * + * FrmStyle::get_one() reads the row with get_post(), which answers for any + * post on the site. Without this guard an unrelated post ID reads back as a + * style, and the write abilities act on that post. + * + * @since x.x + * + * @param stdClass|WP_Post $style The post to check. + * + * @return bool + */ + public static function is_style_post( $style ) { + return isset( $style->post_type ) && FrmStylesController::$post_type === $style->post_type; + } + + /** + * Strip the # prefix from hex color values in style settings. + * + * Formidable stores color values without it, and a stored # makes the + * generated CSS emit ##ffffff, which the browser drops. + * + * Public because Pro's create-style ability normalizes the same way. + * + * @since x.x + * + * @param array $post_content The style settings. + * + * @return array + */ + public static function strip_hex_prefix( $post_content ) { + foreach ( $post_content as $key => $value ) { + if ( is_string( $value ) && str_starts_with( $value, '#' ) ) { + $post_content[ $key ] = substr( $value, 1 ); + } + } + + return $post_content; + } + + /** + * Build the response shape for one style. + * + * @since x.x + * + * @param stdClass|WP_Post $style The style to describe. + * + * @return array + */ + public static function prepare_style_for_response( $style ) { + return array( + 'id' => (int) $style->ID, + 'post_name' => (string) $style->post_name, + 'name' => (string) $style->post_title, + 'post_content' => $style->post_content, + 'menu_order' => (int) $style->menu_order, + 'created_at' => (string) $style->post_date, + 'updated_at' => (string) $style->post_modified, + ); + } + + /** + * Build the error returned when no style matches the given ID. + * + * @since x.x + * + * @return WP_Error + */ + private static function get_invalid_style_error() { + return new WP_Error( 'frm_style_invalid_id', __( 'Invalid style ID.', 'formidable' ), array( 'status' => 404 ) ); + } + + /** + * Permission callback for list styles. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_list_styles( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for get style. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_get_style( $input ) { + return current_user_can( 'frm_view_forms' ) || current_user_can( 'administrator' ); + } + + /** + * Permission callback for update style. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return bool + */ + public static function can_update_style( $input ) { + return current_user_can( 'frm_change_settings' ) || current_user_can( 'administrator' ); + } +} diff --git a/classes/controllers/FrmHooksController.php b/classes/controllers/FrmHooksController.php index 496ed639aa..0e5ad3da4e 100644 --- a/classes/controllers/FrmHooksController.php +++ b/classes/controllers/FrmHooksController.php @@ -137,6 +137,11 @@ public static function load_hooks() { FrmSquareLiteHooksController::load_hooks(); FrmPayPalLiteHooksController::load_hooks(); + // The MCP server and the abilities that drive it. Both stand down on a + // site where an API add-on that predates the move still owns them. + FrmMcpController::load_hooks(); + FrmAbilitiesController::load_hooks(); + // GDPR add_filter( 'frm_is_field_required', 'FrmFieldGdpr::force_required_field', 10, 2 ); } @@ -243,6 +248,7 @@ public static function load_admin_hooks() { FrmSMTPController::load_hooks(); FrmOnboardingWizardController::load_admin_hooks(); FrmAddonsController::load_admin_hooks(); + FrmMcpSettingsController::load_admin_hooks(); new FrmPluginSearch(); } diff --git a/classes/controllers/FrmMcpController.php b/classes/controllers/FrmMcpController.php new file mode 100644 index 0000000000..a5f86cb8a6 --- /dev/null +++ b/classes/controllers/FrmMcpController.php @@ -0,0 +1,419 @@ +mcp ) && null !== $settings->mcp && '' !== $settings->mcp ) { + self::$enabled = (bool) $settings->mcp; + return self::$enabled; + } + + $inherited = self::api_addon_setting(); + + if ( null !== $inherited ) { + self::$enabled = $inherited; + return self::$enabled; + } + + /** + * Whether the MCP server is on for a site that has never saved the setting. + * + * Only the default is filtered. A site owner who has been to the MCP + * settings page has an explicit value stored, and that always wins over + * this, so turning MCP off stays off no matter what a filter says. This + * is for deciding what a site starts out as: a host that has already + * decided its customers should arrive with MCP ready can return true + * from a mu-plugin and skip the settings page entirely. + * + * Because the answer is memoized for the request, add this early + * (mu-plugins or plugins_loaded), not on a later hook. + * + * @since x.x + * + * @param bool $enabled Whether MCP defaults to on. Default false. + */ + self::$enabled = (bool) apply_filters( 'frm_mcp_enabled_by_default', false ); + + return self::$enabled; + } + + /** + * Read the API add-on's MCP toggle, for a site that has not saved Formidable's yet. + * + * Public because the settings section asks the same question, to say on + * screen that the toggle is showing an inherited value. + * + * The option is read directly rather than through the add-on's own settings + * class. That class extends FrmSettings and fills in its own defaults, so + * asking it can never answer "nothing was saved", which is the whole + * question here. A site that never opened the add-on's settings page has no + * stored value and inherits nothing. + * + * @since x.x + * + * @return bool|null Null when the add-on has no stored MCP value to inherit. + */ + public static function api_addon_setting() { + $options = get_option( 'frm_api_options' ); + + if ( is_object( $options ) ) { + // The option holds a serialized FrmAPISettings. The whole point of + // this method is the case where the add-on is not running, and then + // that unserializes to __PHP_Incomplete_Class, where reading a + // property raises a warning and answers nothing. Casting reaches the + // same values without needing the class. + $options = (array) $options; + } + + if ( ! is_array( $options ) || ! isset( $options['mcp'] ) ) { + return null; + } + + return (bool) $options['mcp']; + } + + /** + * Forget the memoized setting. + * + * Needed after the global settings are saved, and by tests, which move the + * setting within one process. + * + * @since x.x + * + * @return void + */ + public static function reset() { + self::$enabled = null; + } + + /** + * Boot the MCP Adapter unless the MCP setting is off. + * + * Booting the adapter also creates its default MCP server, so the whole + * boot is skipped when MCP is disabled, not just the server registration. + * The setting is checked before the adapter is even loaded, so turning MCP + * off keeps the vendored code out of the request entirely. That is what a + * site owner can reach for if the adapter is ever the thing breaking their + * site. + * + * This runs on init because the MCP setting cannot be read any earlier, and + * the adapter only needs to exist by rest_api_init. + * + * @since x.x + * @see action hook init + * + * @return void + */ + public static function maybe_boot_mcp_adapter() { + if ( ! self::is_enabled() ) { + return; + } + + if ( ! FrmMcpCompat::load_adapter() ) { + add_action( 'admin_notices', 'FrmMcpController::mcp_unsupported_notice' ); + return; + } + + add_action( 'mcp_adapter_init', 'FrmMcpController::register_mcp_server' ); + + WP\MCP\Core\McpAdapter::instance(); + } + + /** + * Register a dedicated Formidable MCP server at /wp-json/mcp/formidable-mcp. + * + * This intentionally does not touch the adapter's default server, so other + * plugins that rely on it (or register their own servers) are unaffected. + * The tools are the adapter's discovery and execution meta abilities, which + * resolve every ability flagged mcp.public, including all the + * formidable-forms abilities registered by Formidable, Pro, and Views. + * Those meta abilities are registered by the adapter while creating its + * default server, so this server expects the + * mcp_adapter_create_default_server filter to stay enabled. + * + * @since x.x + * @see action hook mcp_adapter_init + * + * @param WP\MCP\Core\McpAdapter $adapter The MCP Adapter instance. + * + * @return void + */ + public static function register_mcp_server( $adapter ) { + if ( ! self::is_enabled() ) { + return; + } + + // maybe_boot_mcp_adapter() only hooks this once the adapter passed every + // check, so this is here for an adapter booted by something else, which + // fires mcp_adapter_init for everyone hooked to it. + if ( ! FrmMcpCompat::is_usable() ) { + return; + } + + // FrmMcpCompat has already checked that this signature accepts the call, + // so a throw here means an adapter that answered every capability check + // behaves like something else. Catching it keeps a broken dependency + // from taking down every REST request on the site. + try { + $result = $adapter->create_server( + self::SERVER_ID, + self::ROUTE_NAMESPACE, + self::SERVER_ID, + 'Formidable MCP Server', + 'MCP server for Formidable Forms abilities discovery and execution.', + 'v' . FrmAppHelper::plugin_version(), + array( 'WP\\MCP\\Transport\\HttpTransport' ), + 'WP\\MCP\\Infrastructure\\ErrorHandling\\ErrorLogMcpErrorHandler', + 'WP\\MCP\\Infrastructure\\Observability\\NullMcpObservabilityHandler', + array( + 'mcp-adapter/discover-abilities', + 'mcp-adapter/get-ability-info', + 'mcp-adapter/execute-ability', + ) + ); + } catch ( Throwable $e ) { + _doing_it_wrong( __METHOD__, esc_html( 'The MCP adapter rejected the Formidable MCP server: ' . $e->getMessage() ), esc_html( FrmAppHelper::plugin_version() ) ); + return; + }//end try + + if ( is_wp_error( $result ) ) { + _doing_it_wrong( __METHOD__, esc_html( 'Failed to register the Formidable MCP server: ' . $result->get_error_message() ), esc_html( FrmAppHelper::plugin_version() ) ); + } + } + + /** + * Tell an administrator why the MCP server is not running. + * + * Only shown when MCP is turned on and the adapter still cannot be used, so + * the setting reads as enabled while the route answers nothing. Everything + * else on the site keeps working, which is exactly why this would otherwise + * go unnoticed. + * + * @since x.x + * @see action hook admin_notices + * + * @return void + */ + public static function mcp_unsupported_notice() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $reason = FrmMcpCompat::unsupported_reason(); + + if ( '' === $reason ) { + return; + } + + echo '

'; + echo esc_html__( 'The Formidable MCP server is turned on but not running.', 'formidable' ) . ' ' . esc_html( $reason ); + + $loaded_from = FrmMcpCompat::loaded_from(); + + if ( '' !== $loaded_from && ! FrmMcpCompat::is_bundled_copy() ) { + $loaded_by = FrmMcpCompat::loaded_by(); + + echo ' '; + + if ( '' === $loaded_by ) { + printf( + /* translators: %s: absolute path to a PHP file */ + esc_html__( 'The adapter in use was loaded by another plugin, from %s.', 'formidable' ), + '' . esc_html( $loaded_from ) . '' + ); + } else { + printf( + /* translators: 1: plugin folder name, 2: absolute path to a PHP file */ + esc_html__( 'The adapter in use was loaded by the %1$s plugin, from %2$s.', 'formidable' ), + '' . esc_html( $loaded_by ) . '', + '' . esc_html( $loaded_from ) . '' + ); + } + } + + echo '

'; + } + + /** + * Tell a client the MCP setting is off instead of letting it read a bare 404. + * + * Turning MCP off skips the whole adapter boot, so the server route is never + * registered and there is nothing for rest_request_before_callbacks to catch. + * This runs on rest_pre_dispatch, which fires before the route is matched, so + * it can still answer for a route that does not exist. + * + * Two cases are answered. The Formidable server route is always safe, since + * it is ours whoever else is present. The namespace root is only answered + * while nothing else serves under it, so another plugin's MCP server is never + * shadowed by a Formidable setting. + * + * @since x.x + * @see filter hook rest_pre_dispatch + * + * @param mixed $result Result to send to the client, or null to continue dispatching. + * @param WP_REST_Server $server Server instance handling the request. + * @param WP_REST_Request> $request Request being dispatched. + * + * @return mixed + */ + public static function explain_disabled_mcp( $result, $server, $request ) { + if ( null !== $result || ! $request instanceof WP_REST_Request || self::is_enabled() ) { + return $result; + } + + $route = untrailingslashit( $request->get_route() ); + $is_ours = FrmMcpConnection::MCP_ROUTE === $route; + $is_unused = '/mcp' === $route && self::nothing_else_serves_mcp( $server ); + + if ( ! $is_ours && ! $is_unused ) { + return $result; + } + + return new WP_Error( + 'frm_mcp_disabled', + __( 'The Formidable MCP server is turned off. Turn on MCP Server in Formidable > Global Settings > MCP to use it.', 'formidable' ), + array( 'status' => 403 ) + ); + } + + /** + * Check that no MCP server is registered under the mcp namespace. + * + * The namespace belongs to the MCP adapter rather than to Formidable, and + * other plugins can register their own servers on it, so the namespace root + * is only claimed while it is otherwise empty. + * + * @since x.x + * + * @param WP_REST_Server $server Server instance handling the request. + * + * @return bool + */ + private static function nothing_else_serves_mcp( $server ) { + if ( ! is_object( $server ) || ! is_callable( array( $server, 'get_routes' ) ) ) { + return false; + } + + foreach ( array_keys( $server->get_routes() ) as $route ) { + if ( '/mcp' !== $route && str_starts_with( $route, '/mcp/' ) ) { + return false; + } + } + + return true; + } +} diff --git a/classes/controllers/FrmMcpMissingAbilityController.php b/classes/controllers/FrmMcpMissingAbilityController.php new file mode 100644 index 0000000000..1d9dc128ba --- /dev/null +++ b/classes/controllers/FrmMcpMissingAbilityController.php @@ -0,0 +1,343 @@ + array( + 'permission' => 'FrmMcpMissingAbilityController::check_execute_permission', + 'execute' => 'FrmMcpMissingAbilityController::run_execute', + ), + 'mcp-adapter/get-ability-info' => array( + 'permission' => 'FrmMcpMissingAbilityController::check_info_permission', + 'execute' => 'FrmMcpMissingAbilityController::run_info', + ), + ); + } + + /** + * Put Formidable's callbacks in front of an adapter meta ability. + * + * Anything unexpected is left alone. The adapter owns these abilities, and a + * shape this does not recognize is a version that has moved on, where the + * right thing to do is nothing at all rather than break execution. + * + * @since x.x + * @see filter hook wp_register_ability_args + * + * @param array $args Arguments accepted by wp_register_ability(). + * @param string $name Ability name being registered, with its namespace. + * + * @return array + */ + public static function decorate_ability( $args, $name ) { + $wrappers = self::wrappers(); + + if ( ! is_array( $args ) || ! isset( $wrappers[ $name ] ) ) { + return $args; + } + + if ( empty( $args['permission_callback'] ) || empty( $args['execute_callback'] ) ) { + return $args; + } + + self::$originals[ $name ] = array( + 'permission' => $args['permission_callback'], + 'execute' => $args['execute_callback'], + ); + + $args['permission_callback'] = $wrappers[ $name ]['permission']; + $args['execute_callback'] = $wrappers[ $name ]['execute']; + + return $args; + } + + /** + * Permission callback standing in for mcp-adapter/execute-ability. + * + * @since x.x + * + * @param array $input Input parameters, holding ability_name and parameters. + * + * @return mixed The adapter's answer, or true when only the ability name was wrong. + */ + public static function check_execute_permission( $input = array() ) { + return self::check_permission( 'mcp-adapter/execute-ability', $input ); + } + + /** + * Permission callback standing in for mcp-adapter/get-ability-info. + * + * @since x.x + * + * @param array $input Input parameters, holding ability_name. + * + * @return mixed The adapter's answer, or true when only the ability name was wrong. + */ + public static function check_info_permission( $input = array() ) { + return self::check_permission( 'mcp-adapter/get-ability-info', $input ); + } + + /** + * Execute callback standing in for mcp-adapter/execute-ability. + * + * @since x.x + * + * @param array $input Input parameters, holding ability_name and parameters. + * + * @return mixed|WP_Error + */ + public static function run_execute( $input = array() ) { + $error = self::error_for( $input ); + return $error ? $error : self::call_original( 'mcp-adapter/execute-ability', 'execute', $input ); + } + + /** + * Execute callback standing in for mcp-adapter/get-ability-info. + * + * @since x.x + * + * @param array $input Input parameters, holding ability_name. + * + * @return mixed|WP_Error + */ + public static function run_info( $input = array() ) { + $error = self::error_for( $input ); + return $error ? $error : self::call_original( 'mcp-adapter/get-ability-info', 'execute', $input ); + } + + /** + * Let a call through when the only thing wrong with it is the ability name. + * + * The adapter answers ability_not_found only after it has checked that the + * caller is logged in and holds the capability its execution layer needs, so + * that code means the request was allowed and the name was not there. + * Returning true hands the call to the execute callback, which is where a + * message can be returned. Every other answer is passed through untouched, + * so nothing here loosens a permission check. + * + * @since x.x + * + * @param string $name Name of the meta ability being called. + * @param array $input Input parameters, holding the requested ability_name. + * + * @return mixed Whatever the adapter's own callback answered, or true when only the ability name was wrong. + */ + private static function check_permission( $name, $input ) { + $result = self::call_original( $name, 'permission', $input ); + + if ( ! is_wp_error( $result ) || 'ability_not_found' !== $result->get_error_code() ) { + return $result; + } + + return self::should_answer( $input ) ? true : $result; + } + + /** + * Hand the call back to the adapter's own callback. + * + * @since x.x + * + * @param string $name Name of the meta ability being called. + * @param string $which Either permission or execute. + * @param array $input Input parameters passed to the ability. + * + * @return mixed|WP_Error + */ + private static function call_original( $name, $which, $input ) { + $original = self::$originals[ $name ][ $which ] ?? null; + + if ( ! $original || ! is_callable( $original ) ) { + // Only reachable if something unhooked the adapter's callback after + // registration. Nothing here can answer for it. + return new WP_Error( + 'frm_mcp_ability_unusable', + __( 'The MCP server could not run that request. Reload the connection and try again.', 'formidable' ), + array( 'status' => 500 ) + ); + } + + return call_user_func( $original, $input ); + } + + /** + * Check whether Formidable should be answering for this request at all. + * + * @since x.x + * + * @param array $input Input parameters, holding the requested ability_name. + * + * @return bool + */ + private static function should_answer( $input ) { + $name = self::requested_name( $input ); + + if ( '' === $name || wp_has_ability( $name ) ) { + return false; + } + + // The Formidable MCP server reaches every ability on the site, so an + // unregistered name is as likely to be another plugin's as ours. Only + // Formidable's own namespace is Formidable's to explain: answering for + // a WooCommerce name would put a frm_ error code and a Formidable log + // entry on a miss that has nothing to do with Formidable. Anything else + // falls through to the adapter's own not-found answer. + if ( ! FrmMcpAbilityRegistry::owns( $name ) ) { + return false; + } + + // With the Formidable abilities turned off, an add-on install is not the + // answer to anything and the adapter's own message is the honest one. + return FrmAbilitiesController::is_active(); + } + + /** + * Build the answer for a request naming an ability this site cannot reach. + * + * @since x.x + * + * @param array $input Input parameters, holding the requested ability_name. + * + * @return false|WP_Error False when the request is one to leave alone. + */ + private static function error_for( $input ) { + if ( ! self::should_answer( $input ) ) { + return false; + } + + $name = self::requested_name( $input ); + $status = FrmMcpAbilityRegistry::status( $name ); + + self::log( $name, $status ); + + return FrmMcpAbilityRegistry::error( $name, $status ); + } + + /** + * Read the ability name the client asked for. + * + * @since x.x + * + * @param array $input Input parameters passed to the meta ability. + * + * @return string Empty when the input carries no usable name. + */ + private static function requested_name( $input ) { + if ( ! is_array( $input ) || empty( $input['ability_name'] ) || ! is_string( $input['ability_name'] ) ) { + return ''; + } + + return $input['ability_name']; + } + + /** + * Record that an ability was asked for and could not be reached. + * + * Written through the same logger the rest of Formidable uses, so a site + * with the Formidable Logs add-on collects these alongside everything else. + * Without that add-on, the message only reaches the PHP error log while + * WP_DEBUG is on: an assistant retrying a call it cannot make is not a + * reason to fill a production log. + * + * @since x.x + * + * @param string $ability_name Ability name the client asked for. + * @param string $status Status from FrmMcpAbilityRegistry::status(). + * + * @return void + */ + private static function log( $ability_name, $status ) { + if ( self::logged_recently( $ability_name, $status ) ) { + return; + } + + FrmTransLiteLog::log_message( + 'Formidable MCP ability unavailable', + $ability_name . ' was requested over MCP and is not registered on this site (' . $status . ').', + defined( 'WP_DEBUG' ) && WP_DEBUG + ); + } + + /** + * Check whether this ability and status were already logged, and claim the window if not. + * + * A client that cannot make a call tends to make it again immediately, and + * every attempt has the same cause, so one line per ability per window says + * everything the repeats would. + * + * @since x.x + * + * @param string $ability_name Ability name the client asked for. + * @param string $status Status from FrmMcpAbilityRegistry::status(). + * + * @return bool + */ + private static function logged_recently( $ability_name, $status ) { + $key = 'frm_mcp_missing_' . md5( $ability_name . '|' . $status ); + + if ( get_transient( $key ) ) { + return true; + } + + set_transient( $key, time(), 15 * MINUTE_IN_SECONDS ); + + return false; + } +} diff --git a/classes/controllers/FrmMcpSettingsController.php b/classes/controllers/FrmMcpSettingsController.php new file mode 100644 index 0000000000..f14ce62702 --- /dev/null +++ b/classes/controllers/FrmMcpSettingsController.php @@ -0,0 +1,405 @@ + $sections Sections registered for the Global Settings page so far. + * + * @return array + */ + public static function add_settings_section( $sections ) { + if ( ! self::should_show_section() ) { + return $sections; + } + + $sections['mcp'] = array( + 'class' => 'FrmMcpSettingsController', + 'function' => 'route', + 'name' => __( 'MCP', 'formidable' ), + // The same cloud the API section uses, in both its real and placeholder + // form, since the two tabs are two faces of the same feature. + // frm_bolt_icon was here before and is not in images/icons.svg, so the + // tab rendered with no glyph at all. + 'icon' => 'frmfont frm_cloud_icon', + ); + + return $sections; + } + + /** + * Check whether Formidable should show the MCP section. + * + * Hidden while an API add-on that still renders its own MCP toggle is + * active, so the site has one toggle rather than two that disagree. + * + * @since x.x + * + * @return bool + */ + public static function should_show_section() { + return ! self::api_addon_shows_toggle(); + } + + /** + * Check whether the API add-on renders its own MCP toggle. + * + * The add-on drops its toggle in the same release that teaches it to defer, + * and says so with FrmAPISettingsController::renders_mcp_toggle(). An older + * copy has the class but not the method, and always renders one. + * + * @since x.x + * + * @return bool + */ + private static function api_addon_shows_toggle() { + if ( ! class_exists( 'FrmAPISettingsController' ) ) { + return false; + } + + if ( ! method_exists( 'FrmAPISettingsController', 'renders_mcp_toggle' ) ) { + return true; + } + + return (bool) FrmAPISettingsController::renders_mcp_toggle(); + } + + /** + * Render the MCP section of the global settings page. + * + * @since x.x + * + * @return void + */ + public static function route() { + $mcp_enabled = FrmMcpController::is_enabled(); + $connections = FrmMcpCompat::is_usable() ? FrmMcpConnection::get_connections() : null; + $blocked_reason = $mcp_enabled ? FrmMcpCompat::unsupported_reason() : ''; + $is_inherited = self::inherited_from_api_addon(); + $skill_url = self::get_skill_download_url(); + $skill_release = self::get_skill_release(); + $skill_download = self::get_skill_download(); + $skill_is_stale = self::skill_update_available( $skill_release, $skill_download ); + + require FrmAppHelper::plugin_path() . '/classes/views/frm-settings/mcp.php'; + } + + /** + * Check whether the toggle is showing a value inherited from the API add-on. + * + * Worth naming on screen: until the section is saved once, the toggle + * reflects the add-on's setting rather than one of its own, and saving here + * is what takes it over. + * + * @since x.x + * + * @return bool + */ + private static function inherited_from_api_addon() { + $settings = FrmAppHelper::get_settings(); + + if ( isset( $settings->mcp ) && null !== $settings->mcp && '' !== $settings->mcp ) { + return false; + } + + return null !== FrmMcpController::api_addon_setting(); + } + + /** + * Get the download link for the Formidable skill, the instructions an AI + * assistant loads so it knows how to drive the MCP server. + * + * @since x.x + * + * @return string + */ + private static function get_skill_url() { + return 'https://github.com/strategy11/formidable-mcp-skill/releases/latest/download/formidable-mcp-skill.zip'; + } + + /** + * Get the link the Download Skill button points at. + * + * The download goes through this site rather than straight to GitHub, so the + * version each user last took can be recorded and compared against the + * current release. The handler redirects on to the GitHub asset. + * + * @since x.x + * + * @return string + */ + private static function get_skill_download_url() { + $url = admin_url( 'admin-post.php?action=' . self::SKILL_DOWNLOAD_ACTION ); + return wp_nonce_url( $url, self::SKILL_DOWNLOAD_ACTION ); + } + + /** + * Record the download and send the browser on to the skill on GitHub. + * + * @since x.x + * @see action hook admin_post_frm_mcp_download_skill + * + * @return void + */ + public static function download_skill() { + FrmAppHelper::permission_check( 'frm_change_settings' ); + check_admin_referer( self::SKILL_DOWNLOAD_ACTION ); + + // Read past the cache. The button always sends the browser to the current + // release, so a cached version could record something older than the file + // the user is about to get, and then claim an update they already have. + $release = self::get_skill_release( true ); + + update_user_meta( + get_current_user_id(), + self::SKILL_DOWNLOAD_META, + array( + 'time' => time(), + 'version' => $release ? $release['version'] : '', + ) + ); + + // The skill is hosted on GitHub, so that host has to be allowed before + // the safe redirect will send the browser off site. + add_filter( 'allowed_redirect_hosts', 'FrmMcpSettingsController::allow_skill_redirect_host' ); + wp_safe_redirect( self::get_skill_url() ); + exit; + } + + /** + * Allow the skill download host as a redirect target. + * + * @since x.x + * @see filter hook allowed_redirect_hosts + * + * @param array $hosts Hosts a safe redirect may send the browser to. + * + * @return array + */ + public static function allow_skill_redirect_host( $hosts ) { + $host = wp_parse_url( self::get_skill_url(), PHP_URL_HOST ); + + if ( $host ) { + $hosts[] = $host; + } + + return $hosts; + } + + /** + * Get the time and version of the current user's last skill download. + * + * @since x.x + * + * @return array|false Time and version of the last download, or false when this user has never downloaded it. + */ + private static function get_skill_download() { + $download = get_user_meta( get_current_user_id(), self::SKILL_DOWNLOAD_META, true ); + + if ( ! is_array( $download ) || empty( $download['time'] ) ) { + return false; + } + + return $download; + } + + /** + * Check whether a release has come out since this user last downloaded the skill. + * + * The two versions are compared for equality rather than order, so a tag + * that does not read as a version number still reports the change. + * + * @since x.x + * + * @param array|false $release The current release, from get_skill_release(). + * @param array|false $download The user's last download, from get_skill_download(). + * + * @return bool + */ + private static function skill_update_available( $release, $download ) { + if ( ! $release || ! $download || empty( $download['version'] ) ) { + // Nothing to compare against until both versions are known. + return false; + } + + return $release['version'] !== $download['version']; + } + + /** + * Format a skill release or download date as a short relative phrase. + * + * These dates read inside a sentence beside the download button, where a + * full date makes the line long enough to wrap. Today is named rather than + * counted, since '4 hours ago' is more precision than the line needs. + * + * @since x.x + * + * @param int|string $date A timestamp, or the date string the GitHub API returned. + * + * @return string A phrase like 'today' or '3 days ago', or an empty string when the date cannot be read. + */ + public static function relative_skill_date( $date ) { + $timestamp = is_numeric( $date ) ? (int) $date : strtotime( $date ); + + if ( ! $timestamp ) { + return ''; + } + + $now = time(); + + if ( $timestamp > $now ) { + // A release published moments ago can still read as the future once + // the site time zone is applied, and 'in 2 hours' would be wrong. + $timestamp = $now; + } + + if ( wp_date( 'Y-m-d', $timestamp ) === wp_date( 'Y-m-d', $now ) ) { + return __( 'today', 'formidable' ); + } + + /* translators: %s: Human readable time difference, like "3 days". */ + $phrase = sprintf( __( '%s ago', 'formidable' ), human_time_diff( $timestamp, $now ) ); + + return $phrase ? $phrase : ''; + } + + /** + * Get the latest published release of the skill from the GitHub API. + * + * The result is cached, since the settings page would otherwise call GitHub + * on every load. An hour keeps the version on screen close to the real one + * while staying far inside the 60 unauthenticated requests an hour per IP + * that GitHub allows, and a failure is cached for a shorter time so an + * outage does not send a request on every load either. + * + * @since x.x + * + * @param bool $refresh Whether to skip the cache and read the release from GitHub. + * + * @return array|false Version, publish date, and release page URL, or false when the release cannot be read. + */ + private static function get_skill_release( $refresh = false ) { + $cached = get_transient( self::SKILL_RELEASE_TRANSIENT ); + // An empty array is the cached failure, and false is nothing cached at all. + $cached = is_array( $cached ) ? $cached : null; + + if ( ! $refresh && null !== $cached ) { + return array() === $cached ? false : $cached; + } + + $release = self::fetch_skill_release(); + + if ( ! $release ) { + if ( $cached ) { + // A refresh that failed keeps the release already cached rather + // than dropping the version that is on screen. + return $cached; + } + + set_transient( self::SKILL_RELEASE_TRANSIENT, array(), 15 * MINUTE_IN_SECONDS ); + return false; + } + + set_transient( self::SKILL_RELEASE_TRANSIENT, $release, HOUR_IN_SECONDS ); + + return $release; + } + + /** + * Read the latest release from the GitHub API. + * + * The endpoint is public and skips drafts and prereleases, so a tagged + * release candidate does not replace the last stable one. + * + * @since x.x + * + * @return array|false Version, publish date, and release page URL, or false when the release cannot be read. + */ + private static function fetch_skill_release() { + $response = wp_remote_get( + 'https://api.github.com/repos/Strategy11/formidable-mcp-skill/releases/latest', + array( + 'timeout' => 10, + 'headers' => array( 'Accept' => 'application/vnd.github+json' ), + ) + ); + + $body = array(); + + if ( 200 === wp_remote_retrieve_response_code( $response ) ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + } + + if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) { + return false; + } + + return array( + // published_at is when the release went out. created_at is older, since it dates the tagged commit. + 'published' => $body['published_at'] ?? '', + 'url' => $body['html_url'] ?? '', + 'version' => $body['tag_name'], + ); + } +} diff --git a/classes/helpers/FrmAbilitiesHelper.php b/classes/helpers/FrmAbilitiesHelper.php new file mode 100644 index 0000000000..67d78cf71c --- /dev/null +++ b/classes/helpers/FrmAbilitiesHelper.php @@ -0,0 +1,263 @@ + true, + 'mcp' => array( + 'public' => true, + ), + 'annotations' => array( + 'readonly' => $readonly, + 'destructive' => $destructive, + 'idempotent' => $idempotent, + ), + ); + } + + /** + * Resolve the user behind an MCP or Abilities API request. + * + * The abilities run inside a REST request that has already authenticated, + * but the current user is not always set on the global by the time an + * execute callback runs. Asking the determine_current_user filter again is + * what the permission callbacks are checked against, so it has to happen + * before any capability check. + * + * @since x.x + * + * @return void + */ + public static function set_current_user() { + if ( is_user_logged_in() ) { + return; + } + + $user_id = apply_filters( 'determine_current_user', false ); + + if ( $user_id ) { + wp_set_current_user( $user_id ); + } + } + + /** + * Build the list of field types the create abilities accept. + * + * Derived from the field registry instead of a hard-coded list so every + * installed type (Formidable, Pro, and add-ons) is creatable. Types whose + * add-on is not installed resolve to the Default placeholder class and are + * excluded, since storing them would render a field with no input. The + * palette repeater key ('divider|repeat') and the auto-managed submit field + * are excluded, and the schema aliases the create abilities normalize are + * appended. + * + * @since x.x + * + * @return array + */ + public static function get_creatable_field_types() { + $types = array(); + + foreach ( array_keys( FrmField::all_field_selection() ) as $type ) { + if ( ! is_string( $type ) || str_contains( $type, '|' ) || 'submit' === $type ) { + continue; + } + + $field_type = FrmFieldFactory::get_field_type( $type ); + + if ( in_array( get_class( $field_type ), array( 'FrmFieldDefault', 'FrmProFieldDefault' ), true ) ) { + continue; + } + + $types[] = $type; + } + + return array_merge( $types, array( 'dropdown', 'star_rating', 'section' ) ); + } + + /** + * Lowercase the order input so downstream queries always receive asc or desc. + * + * The input schemas accept both casings because clients (LLMs especially) + * send DESC as often as desc, but internally one consistent case is used. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array + */ + public static function normalize_order( $input ) { + if ( isset( $input['order'] ) && is_string( $input['order'] ) ) { + $input['order'] = strtolower( $input['order'] ); + } + + return $input; + } + + /** + * Build the ORDER BY and LIMIT clauses for a paged list ability. + * + * Every number is cast before it reaches the clause and the column goes + * through FrmDb::esc_order, so nothing here carries a caller's string into + * SQL. The page size is capped: an ability is answering a language model, + * and an unbounded page is a way to exhaust memory by accident. + * + * @since x.x + * + * @param array $input Ability input parameters. + * + * @return array The order clause and the limit clause, in that order. + */ + public static function prepare_order_and_limit( $input ) { + $page_size = 50; + + if ( ! empty( $input['limit'] ) ) { + $page_size = absint( $input['limit'] ); + } elseif ( ! empty( $input['page_size'] ) ) { + $page_size = absint( $input['page_size'] ); + } + + $page_size = min( max( $page_size, 1 ), 200 ); + $page = ! empty( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; + $order_by = ! empty( $input['order_by'] ) && is_string( $input['order_by'] ) ? $input['order_by'] : 'created_at'; + $order = ! empty( $input['order'] ) && is_string( $input['order'] ) ? $input['order'] : 'DESC'; + $offset = $page_size * ( $page - 1 ); + + return array( + FrmDb::esc_order( ' ORDER BY ' . $order_by . ' ' . $order ), + ' LIMIT ' . $offset . ',' . $page_size, + ); + } + + /** + * Flatten a WP_Error carrying an array message into one readable string. + * + * The adapter's output schema requires error to be a string, but validation + * errors arrive as per field maps. An array message fails output validation, + * and the client is then told the output was malformed rather than which + * field was wrong. + * + * @since x.x + * + * @param WP_Error $error The error to flatten. + * + * @return WP_Error + */ + public static function flatten_error( $error ) { + $messages = array(); + + foreach ( $error->get_error_codes() as $code ) { + foreach ( (array) $error->errors[ $code ] as $message ) { + if ( ! is_array( $message ) ) { + $messages[] = (string) $message; + continue; + } + + foreach ( $message as $key => $msg ) { + $messages[] = is_string( $key ) ? $key . ': ' . $msg : (string) $msg; + } + } + } + + return new WP_Error( $error->get_error_code(), implode( '; ', $messages ), $error->get_error_data() ); + } + + /** + * Build the error returned when an ability needs a plugin that is not active. + * + * @since x.x + * + * @param string $plugin_name Display name of the plugin the ability needs, such as Formidable Forms Pro. + * + * @return WP_Error + */ + public static function missing_plugin_error( $plugin_name ) { + return new WP_Error( + 'frm_plugin_required', + sprintf( + /* translators: %s: the name of a Formidable plugin, such as Formidable Forms Pro */ + __( 'This action needs %s, which is not active on this site.', 'formidable' ), + $plugin_name + ), + array( 'status' => 403 ) + ); + } + + /** + * Look up a form by id or key, for the abilities that accept either. + * + * @since x.x + * + * @param int|string $id Form ID or form_key. + * + * @return stdClass|WP_Error The form, or an error when nothing matches. + */ + public static function get_form( $id ) { + $form = FrmForm::getOne( $id ); + + if ( ! $form ) { + return new WP_Error( + 'frm_form_not_found', + __( 'No form was found with that ID or key.', 'formidable' ), + array( 'status' => 404 ) + ); + } + + return $form; + } +} diff --git a/classes/models/FrmMcpAbilityRegistry.php b/classes/models/FrmMcpAbilityRegistry.php new file mode 100644 index 0000000000..a82e1bc007 --- /dev/null +++ b/classes/models/FrmMcpAbilityRegistry.php @@ -0,0 +1,519 @@ + array( + 'title' => 'Formidable Forms Pro', + 'folder' => 'formidable-pro', + 'abilities' => array( + 'entry-writes' => array( + 'formidable-forms/create-entry', + 'formidable-forms/update-entry', + ), + 'styles-pro' => array( + 'formidable-forms/create-style', + 'formidable-forms/delete-style', + 'formidable-forms/assign-style-to-form', + ), + 'stats' => array( + 'formidable-forms/get-stats', + ), + 'applications' => array( + 'formidable-forms/list-applications', + 'formidable-forms/get-application', + 'formidable-forms/create-application', + 'formidable-forms/delete-application', + 'formidable-forms/list-application-items', + 'formidable-forms/add-item-to-application', + 'formidable-forms/remove-item-from-application', + ), + ), + ), + 'views' => array( + 'title' => 'Formidable Views', + 'folder' => 'formidable-views', + 'abilities' => array( + 'views' => array( + 'formidable-forms/list-views', + 'formidable-forms/get-view', + 'formidable-forms/create-view', + 'formidable-forms/update-view', + 'formidable-forms/delete-view', + ), + 'view-layouts' => array( + 'formidable-forms/list-view-layouts', + 'formidable-forms/get-view-layout', + 'formidable-forms/create-view-layout', + 'formidable-forms/update-view-layout', + 'formidable-forms/delete-view-layout', + ), + ), + ), + 'coupons' => array( + 'title' => 'Formidable Coupons', + 'folder' => 'formidable-coupons', + 'abilities' => array( + 'coupons' => array( + 'formidable-forms/list-coupons', + 'formidable-forms/get-coupon', + 'formidable-forms/create-coupon', + 'formidable-forms/update-coupon', + 'formidable-forms/delete-coupon', + ), + ), + ), + 'landing' => array( + 'title' => 'Formidable Landing Pages', + 'folder' => 'formidable-landing', + 'abilities' => array( + 'landing-pages' => array( + 'formidable-forms/list-landing-pages', + 'formidable-forms/get-landing-page', + 'formidable-forms/save-landing-page', + 'formidable-forms/update-landing-page', + 'formidable-forms/delete-landing-page', + ), + ), + ), + ); + + /** + * Filter the abilities Formidable knows about but may not be able to see registered. + * + * An add-on adds one entry keyed by a short provider name, holding its + * display title, its plugin folder, and the ability names it owns grouped + * by the domain names it announces on frm_ability_domains. Registering + * the same names in the Abilities API is still what makes them work: + * this list is only consulted when the name is missing. + * + * @since x.x + * + * @param array $providers Provider keys mapped to a title, plugin folder, and ability names grouped by domain. + */ + self::$providers = (array) apply_filters( 'frm_mcp_ability_providers', $providers ); + + return self::$providers; + } + + /** + * Forget the memoized providers. + * + * Needed by tests, which move the filter within one process. + * + * @since x.x + * + * @return void + */ + public static function reset() { + self::$providers = null; + } + + /** + * Check whether an ability name is in Formidable's namespace. + * + * The Formidable MCP server reaches every ability registered on the site, + * so a name arriving from a client can belong to WooCommerce or any other + * plugin. This is the test for whether a name is Formidable's business at + * all, and it works on a name that is not registered, which is the case + * this class exists to answer for. + * + * @since x.x + * + * @param string $ability_name Full ability name, including its namespace. + * + * @return bool + */ + public static function owns( $ability_name ) { + if ( ! is_string( $ability_name ) || '' === $ability_name ) { + return false; + } + + return str_starts_with( $ability_name, FrmAbilitiesController::CATEGORY . '/' ); + } + + /** + * Find the provider that owns one ability name. + * + * @since x.x + * + * @param string $ability_name Full ability name, including the formidable-forms prefix. + * + * @return array|false The provider entry, or false when no provider claims the name. + */ + public static function provider_for( $ability_name ) { + $found = self::locate( $ability_name ); + + if ( ! $found || ! is_array( $found['provider'] ) ) { + return false; + } + + return $found['provider']; + } + + /** + * Find the provider and the ability domain that own one ability name. + * + * @since x.x + * + * @param string $ability_name Full ability name, including the formidable-forms prefix. + * + * @return array|false The provider entry and its domain name, or false when no provider claims the name. + */ + private static function locate( $ability_name ) { + foreach ( self::providers() as $provider ) { + if ( ! is_array( $provider ) || empty( $provider['abilities'] ) ) { + continue; + } + + foreach ( (array) $provider['abilities'] as $domain => $names ) { + if ( in_array( $ability_name, (array) $names, true ) ) { + return array( + 'provider' => $provider, + 'domain' => $domain, + ); + } + } + } + + return false; + } + + /** + * Work out why one ability name cannot be reached on this site. + * + * Only meaningful for a name the Abilities API does not have. A registered + * ability is answered by the Abilities API itself and never gets here. + * + * @since x.x + * + * @param string $ability_name Full ability name, including the formidable-forms prefix. + * + * @return string One of the STATUS_ constants. + */ + public static function status( $ability_name ) { + $found = self::locate( $ability_name ); + + if ( ! $found ) { + return self::STATUS_UNKNOWN; + } + + $folder = isset( $found['provider']['folder'] ) ? (string) $found['provider']['folder'] : ''; + $basenames = '' === $folder ? array() : self::installed_basenames( $folder ); + + if ( ! $basenames ) { + return self::STATUS_NOT_INSTALLED; + } + + if ( ! self::any_active( $basenames ) ) { + return self::STATUS_INACTIVE; + } + + if ( ! self::domain_is_claimed( $found['domain'] ) ) { + // The plugin is running but never announced the domain, so this copy + // of it is older than the feature the ability belongs to. + return self::STATUS_OUTDATED; + } + + if ( self::license_is_expired() ) { + return self::STATUS_EXPIRED; + } + + return self::STATUS_UNAVAILABLE; + } + + /** + * Check whether a plugin has announced that it owns one ability domain. + * + * Pro and Views add their domains on the frm_ability_domains filter whatever + * the license says, and only stop registering the abilities themselves, so + * the claim is the signal for "the feature is here" and the missing ability + * is the signal for "something stopped it". + * + * @since x.x + * + * @param string $domain Domain name, such as views or applications. + * + * @return bool + */ + private static function domain_is_claimed( $domain ) { + $domains = FrmAbilitiesController::domains(); + return isset( $domains[ $domain ] ); + } + + /** + * Build the error a client gets for an ability it cannot reach. + * + * @since x.x + * + * @param string $ability_name Full ability name, including the formidable-forms prefix. + * @param string $status One of the STATUS_ constants, from status(). + * + * @return WP_Error + */ + public static function error( $ability_name, $status ) { + $provider = self::provider_for( $ability_name ); + $title = $provider && ! empty( $provider['title'] ) ? (string) $provider['title'] : ''; + + return new WP_Error( + self::error_code( $status ), + self::message( $status, $title, $ability_name ), + array( 'status' => self::STATUS_UNKNOWN === $status ? 404 : 403 ) + ); + } + + /** + * Map a status to the error code a client can branch on. + * + * @since x.x + * + * @param string $status One of the STATUS_ constants. + * + * @return string + */ + private static function error_code( $status ) { + $codes = array( + self::STATUS_NOT_INSTALLED => 'frm_ability_addon_missing', + self::STATUS_INACTIVE => 'frm_ability_addon_inactive', + self::STATUS_OUTDATED => 'frm_ability_addon_outdated', + self::STATUS_EXPIRED => 'frm_ability_license_expired', + self::STATUS_UNAVAILABLE => 'frm_ability_unavailable', + ); + + return $codes[ $status ] ?? 'frm_ability_unknown'; + } + + /** + * Build the message that tells the caller what to do about it. + * + * Each message names the plugin and the next step, and none of them mentions + * a license key or a path on disk. + * + * @since x.x + * + * @param string $status One of the STATUS_ constants. + * @param string $title Display title of the plugin that provides the ability. + * @param string $ability_name Full ability name, including the formidable-forms prefix. + * + * @return string + */ + private static function message( $status, $title, $ability_name ) { + if ( self::STATUS_UNKNOWN === $status || '' === $title ) { + return sprintf( + /* translators: %s: an ability name, such as formidable-forms/create-view */ + __( 'There is no ability named %s on this site. Run the discover abilities tool to see which abilities this site has.', 'formidable' ), + $ability_name + ); + } + + return sprintf( self::message_template( $status ), $title, $ability_name ); + } + + /** + * Get the untranslated-argument template for one status. + * + * @since x.x + * + * @param string $status One of the STATUS_ constants. + * + * @return string A template taking the plugin title as %1$s and the ability name as %2$s. + */ + private static function message_template( $status ) { + switch ( $status ) { + case self::STATUS_NOT_INSTALLED: + /* translators: 1: a plugin name, such as Formidable Views, 2: an ability name */ + return __( 'This action needs %1$s, which is not installed on this site. Install and activate %1$s from Formidable > Add-Ons, then run %2$s again.', 'formidable' ); + + case self::STATUS_INACTIVE: + /* translators: 1: a plugin name, such as Formidable Views, 2: an ability name */ + return __( 'This action needs %1$s, which is installed but not active. Activate %1$s on the Plugins screen, then run %2$s again.', 'formidable' ); + + case self::STATUS_OUTDATED: + /* translators: 1: a plugin name, such as Formidable Views, 2: an ability name */ + return __( 'This action needs a newer version of %1$s. Update %1$s to the latest version, then run %2$s again.', 'formidable' ); + + case self::STATUS_EXPIRED: + /* translators: 1: a plugin name, such as Formidable Views, 2: an ability name */ + return __( '%1$s is active but is not offering %2$s because its license is expired. Renew it in Formidable > Global Settings, then try again.', 'formidable' ); + + default: + /* translators: 1: a plugin name, such as Formidable Views, 2: an ability name */ + return __( '%1$s is active but did not register %2$s on this site. Check that %1$s is up to date and that its license is active, then try again.', 'formidable' ); + }//end switch + } + + /** + * List the installed plugin files that live in one plugin folder. + * + * Matched on the folder rather than on one folder/file.php, because the main + * file has been renamed across versions of some of these add-ons and the + * answer here only needs to be "is a copy of it on disk". + * + * @since x.x + * + * @param string $folder Plugin folder name, such as formidable-views. + * + * @return array Plugin basenames in that folder. + */ + private static function installed_basenames( $folder ) { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $found = array(); + + foreach ( array_keys( get_plugins() ) as $basename ) { + if ( $folder === dirname( $basename ) ) { + $found[] = $basename; + } + } + + return $found; + } + + /** + * Check whether any of the given plugin files is active. + * + * @since x.x + * + * @param array $basenames Plugin basenames, as returned by installed_basenames(). + * + * @return bool + */ + private static function any_active( $basenames ) { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + foreach ( $basenames as $basename ) { + if ( is_plugin_active( $basename ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether the license stops the add-ons registering anything. + * + * There is one license for the whole family, and Pro and Views both stand + * down once it is expired and the grace period is over, so one answer covers + * both. Pro owns the answer: the grace period is a timestamp the store + * supplies rather than a fixed duration, and the methods that read it are + * private, so get_license_status() is the only correct source. A site still + * inside the grace period reports grace, not expired, and keeps working. + * + * Only the status word is read. No license key is fetched, compared, or + * reported anywhere in this class. + * + * @since x.x + * + * @return bool + */ + private static function license_is_expired() { + if ( is_callable( 'FrmProAddonsController::get_license_status' ) && 'expired' === FrmProAddonsController::get_license_status() ) { + return true; + } + + // Pro can also be running unauthorized, which never reaches an expired + // license status because there is no license to read. + return ! FrmAppHelper::pro_is_installed(); + } +} diff --git a/classes/models/FrmMcpCompat.php b/classes/models/FrmMcpCompat.php new file mode 100644 index 0000000000..a51956a3f1 --- /dev/null +++ b/classes/models/FrmMcpCompat.php @@ -0,0 +1,492 @@ +getNumberOfParameters() < $passed || $method->getNumberOfRequiredParameters() > $passed ) { + return __( 'The create_server() method in the MCP adapter on this site does not take the arguments the Formidable MCP server passes.', 'formidable' ); + } + + // The declared names are collected first, then walked from the expected + // list rather than the other way round. Reflection hands back an + // unbounded position, and comparing it against the count is not enough + // for a static analyzer to accept it as an offset into a fixed list, so + // the loop is driven by the list whose keys are known instead. + $declared = array(); + + foreach ( $method->getParameters() as $parameter ) { + $declared[] = $parameter->getName(); + } + + foreach ( self::CREATE_SERVER_PARAMS as $position => $expected_name ) { + $declared_name = $declared[ $position ] ?? ''; + + if ( $expected_name !== $declared_name ) { + return sprintf( + /* translators: 1: parameter name the Formidable MCP server expects, 2: parameter name the adapter declares */ + __( + /* translators: 1: parameter name the Formidable MCP server expects, 2: parameter name the adapter declares */ + 'The MCP adapter on this site declares create_server() with its arguments in a different order. It declares %2$s where %1$s was expected.', + 'formidable' + ), + $expected_name, + $declared_name + ); + } + } + + return ''; + } + + /** + * Get the version of the adapter that is loaded. + * + * For notices and support only, never for a compatibility decision. This is + * whatever the loaded copy declares, and v0.3.0 declares 0.1.0, so on a site + * running WooCommerce's copy it names an older release than the code around + * it. To identify a copy for certain, read the wordpress/mcp-adapter entry + * in the composer/installed.json of the vendor tree loaded_from() points into. + * + * @since x.x + * + * @return string Empty string when no adapter is loaded, or it has no version constant. + */ + public static function loaded_version() { + if ( ! class_exists( self::ADAPTER_CLASS ) ) { + return ''; + } + + $constant = self::ADAPTER_CLASS . '::VERSION'; + + return defined( $constant ) ? constant( $constant ) : ''; + } + + /** + * Get the file the loaded adapter class came from. + * + * @since x.x + * + * @return string Empty string when no adapter is loaded, or the file is unknown. + */ + public static function loaded_from() { + if ( ! class_exists( self::ADAPTER_CLASS ) ) { + return ''; + } + + /** @var class-string $adapter_class */ + $adapter_class = self::ADAPTER_CLASS; + $reflection = new ReflectionClass( $adapter_class ); + $file = $reflection->getFileName(); + + return is_string( $file ) ? wp_normalize_path( $file ) : ''; + } + + /** + * Check whether the loaded adapter is the copy that ships in this plugin. + * + * A copy from somewhere else is the normal case on a site with more than one + * MCP plugin, and is not a problem by itself: whichever copy answers first + * is the one the whole site uses, and the capability checks cover what this + * plugin needs from it. It is only worth naming in a notice, where it + * explains why a working adapter sits in lib/vendor while a different one + * is running. + * + * @since x.x + * + * @return bool + */ + public static function is_bundled_copy() { + $file = self::loaded_from(); + + if ( '' === $file ) { + return false; + } + + $bundled = wp_normalize_path( FrmAppHelper::plugin_path() . '/lib/vendor/wordpress/mcp-adapter/' ); + + return str_starts_with( $file, $bundled ); + } + + /** + * Name the plugin folder the loaded adapter came from. + * + * The path alone already appears in the notice, but the folder is what an + * administrator can act on, since it is what they would deactivate or + * update to change which copy wins. + * + * @since x.x + * + * @return string Empty string when the adapter is not inside a plugin. + */ + public static function loaded_by() { + $file = self::loaded_from(); + + if ( '' === $file ) { + return ''; + } + + // Both paths go through realpath first. WP_PLUGIN_DIR is built from + // ABSPATH, which keeps whatever shape the entry point gave it, so it can + // carry a './' segment that a plain string comparison never matches. + $plugins = realpath( WP_PLUGIN_DIR ); + $real = realpath( $file ); + + if ( false === $plugins || false === $real ) { + return ''; + } + + $plugins = wp_normalize_path( $plugins ) . '/'; + $real = wp_normalize_path( $real ); + + if ( ! str_starts_with( $real, $plugins ) ) { + return ''; + } + + $folder = strtok( substr( $real, strlen( $plugins ) ), '/' ); + + return false === $folder ? '' : $folder; + } + + /** + * Forget the memoized results. + * + * Only needed by tests, which move the adapter in and out of the checks + * within one process. + * + * @since x.x + * + * @return void + */ + public static function reset() { + self::$loaded = null; + self::$reason = null; + } +} diff --git a/classes/models/FrmMcpConnection.php b/classes/models/FrmMcpConnection.php new file mode 100644 index 0000000000..40e246b209 --- /dev/null +++ b/classes/models/FrmMcpConnection.php @@ -0,0 +1,394 @@ +> $request Request used to generate the response. + * + * @return mixed + */ + public static function log_mcp_request( $response, $handler, $request ) { + if ( is_wp_error( $response ) || ! $request instanceof WP_REST_Request || self::MCP_ROUTE !== untrailingslashit( $request->get_route() ) ) { + return $response; + } + + $user_id = get_current_user_id(); + + if ( ! $user_id ) { + return $response; + } + + $body = $request->get_json_params(); + + if ( ! is_array( $body ) ) { + $body = array(); + } + + $endpoint = self::get_endpoint( $body ); + + self::log_request( $user_id, $endpoint ); + + /** + * Fires for every authenticated request to the Formidable MCP server. + * + * The API add-on hooks this to count the endpoint and the client in + * Formidable's usage tracking, which is where this lived before the MCP + * server moved into Formidable itself. + * + * @since x.x + * + * @param array $body Decoded JSON-RPC request body. + * @param string $endpoint Endpoint name, or an empty string for protocol requests. + */ + do_action( 'frm_mcp_request', $body, $endpoint ); + + return $response; + } + + /** + * Get the endpoint name from a JSON-RPC request body. + * + * For ability executions this is the ability name (formidable-forms/list-forms), + * for other tool calls the tool name, and for resource and prompt requests + * the resource URI or prompt name. Protocol requests like initialize and + * tools/list return an empty string so they only bump the last request time. + * + * @since x.x + * + * @param array $body Decoded JSON-RPC request body. + * + * @return string + */ + private static function get_endpoint( $body ) { + $method = isset( $body['method'] ) && is_string( $body['method'] ) ? $body['method'] : ''; + $params = isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array(); + + if ( 'tools/call' === $method ) { + if ( isset( $params['arguments']['ability_name'] ) && is_string( $params['arguments']['ability_name'] ) ) { + $ability_name = $params['arguments']['ability_name']; + + // The Formidable MCP server reaches every ability registered on + // the site, WooCommerce's and any other plugin's included, so an + // ability name arriving here is not necessarily one of ours. + // This table is Formidable's own activity log, so a name we do + // not own is recorded as a protocol request: the connection + // still counts, the endpoint is not listed. + return self::is_formidable_ability( $ability_name ) ? $ability_name : ''; + } + + return isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : ''; + } + + if ( 'resources/read' === $method ) { + return isset( $params['uri'] ) && is_string( $params['uri'] ) ? $params['uri'] : ''; + } + + if ( 'prompts/get' === $method ) { + return isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : ''; + } + + return ''; + } + + /** + * Check whether an ability name is one Formidable owns. + * + * A registered ability is judged by the category it registered into, which + * is what every Formidable ability carries, add-on abilities included. When + * the name is not registered the registry answers instead, so an ability + * belonging to an add-on this site does not have is still recognised as + * ours rather than being mistaken for another plugin's. + * + * @since x.x + * + * @param string $ability_name Full ability name, including its namespace. + * + * @return bool + */ + private static function is_formidable_ability( $ability_name ) { + if ( '' === $ability_name ) { + return false; + } + + if ( function_exists( 'wp_get_ability' ) ) { + $ability = wp_get_ability( $ability_name ); + + if ( $ability && is_callable( array( $ability, 'get_category' ) ) ) { + $category = $ability->get_category(); + + if ( is_object( $category ) && is_callable( array( $category, 'get_name' ) ) ) { + $category = $category->get_name(); + } + + return FrmAbilitiesController::CATEGORY === $category; + } + } + + return FrmMcpAbilityRegistry::owns( $ability_name ); + } + + /** + * Record one MCP request for a user. + * + * @since x.x + * + * @param int $user_id ID of the connecting user. + * @param string $endpoint Endpoint name, or an empty string for protocol requests. + * + * @return void + */ + public static function log_request( $user_id, $endpoint = '' ) { + $now = time(); + $activity = self::get_activity( $user_id ); + + $activity['last_request'] = $now; + + if ( '' !== $endpoint ) { + $endpoint = substr( sanitize_text_field( $endpoint ), 0, 200 ); + + if ( isset( $activity['endpoints'][ $endpoint ] ) ) { + $activity['endpoints'][ $endpoint ]['time'] = $now; + ++$activity['endpoints'][ $endpoint ]['count']; + } else { + $activity['endpoints'][ $endpoint ] = array( + 'time' => $now, + 'count' => 1, + ); + } + + $activity['endpoints'] = self::cap_endpoints( $activity['endpoints'] ); + } + + update_user_meta( $user_id, self::META_KEY, $activity ); + } + + /** + * Drop the oldest endpoints once the list grows past the limit. + * + * @since x.x + * + * @param array $endpoints Endpoint names mapped to time and count data. + * + * @return array + */ + private static function cap_endpoints( $endpoints ) { + $endpoint_count = count( $endpoints ); + + while ( $endpoint_count > self::MAX_ENDPOINTS ) { + $oldest_key = ''; + $oldest_time = PHP_INT_MAX; + + foreach ( $endpoints as $endpoint => $endpoint_data ) { + if ( $endpoint_data['time'] >= $oldest_time ) { + continue; + } + + $oldest_time = $endpoint_data['time']; + $oldest_key = $endpoint; + } + + unset( $endpoints[ $oldest_key ] ); + --$endpoint_count; + } + + return $endpoints; + } + + /** + * Get the stored MCP activity for one user. + * + * @since x.x + * + * @param int $user_id ID of the user to look up. + * + * @return array{last_request: int, endpoints: array} + */ + private static function get_activity( $user_id ) { + $activity = get_user_meta( $user_id, self::META_KEY, true ); + + if ( ! is_array( $activity ) ) { + $activity = array(); + } + + $endpoints = array(); + + if ( isset( $activity['endpoints'] ) && is_array( $activity['endpoints'] ) ) { + foreach ( $activity['endpoints'] as $endpoint => $endpoint_data ) { + if ( ! is_string( $endpoint ) || ! is_array( $endpoint_data ) ) { + continue; + } + + $endpoints[ $endpoint ] = array( + 'time' => isset( $endpoint_data['time'] ) ? max( 0, (int) $endpoint_data['time'] ) : 0, + 'count' => isset( $endpoint_data['count'] ) ? max( 0, (int) $endpoint_data['count'] ) : 1, + ); + } + } + + return array( + 'last_request' => isset( $activity['last_request'] ) ? max( 0, (int) $activity['last_request'] ) : 0, + 'endpoints' => $endpoints, + ); + } + + /** + * Get the MCP connection summary for every user with recorded activity. + * + * Rows are sorted by the most recent request first, and each row's + * endpoints are sorted by the most recently called first, limited to + * those called within the last month. + * + * @since x.x + * + * @return array}> + */ + public static function get_connections() { + $users = get_users( + array( + 'meta_key' => self::META_KEY, + ) + ); + + $connections = array(); + + foreach ( $users as $user ) { + if ( ! $user instanceof WP_User ) { + continue; + } + + $activity = self::get_activity( $user->ID ); + + if ( ! $activity['last_request'] ) { + continue; + } + + $activity['endpoints'] = self::filter_recent_endpoints( $activity['endpoints'] ); + + uasort( $activity['endpoints'], 'FrmMcpConnection::compare_endpoint_time' ); + + $connections[] = array( + 'user_login' => $user->user_login, + 'display_name' => $user->display_name, + 'last_request' => $activity['last_request'], + 'endpoints' => $activity['endpoints'], + ); + }//end foreach + + usort( $connections, 'FrmMcpConnection::compare_last_request' ); + + return $connections; + } + + /** + * Sort two endpoint rows so the most recently called comes first. + * + * @since x.x + * + * @param array{time: int, count: int} $a First endpoint's time and count data. + * @param array{time: int, count: int} $b Second endpoint's time and count data. + * + * @return int + */ + public static function compare_endpoint_time( $a, $b ) { + return $b['time'] - $a['time']; + } + + /** + * Sort two connection rows so the most recent request comes first. + * + * @since x.x + * + * @param array $a First connection row. + * @param array $b Second connection row. + * + * @return int + */ + public static function compare_last_request( $a, $b ) { + return $b['last_request'] - $a['last_request']; + } + + /** + * Keep only the endpoints called within the last month. + * + * @since x.x + * + * @param array $endpoints Endpoint names mapped to time and count data. + * + * @return array + */ + private static function filter_recent_endpoints( $endpoints ) { + $cutoff = strtotime( '-1 month' ); + $recent = array(); + + foreach ( $endpoints as $endpoint => $endpoint_data ) { + if ( $endpoint_data['time'] >= $cutoff ) { + $recent[ $endpoint ] = $endpoint_data; + } + } + + return $recent; + } +} diff --git a/classes/models/FrmSettings.php b/classes/models/FrmSettings.php index 88b1208426..94f3121c2a 100644 --- a/classes/models/FrmSettings.php +++ b/classes/models/FrmSettings.php @@ -242,6 +242,21 @@ class FrmSettings { */ public $allowed_words; + /** + * Whether the Formidable MCP server and the Formidable abilities are registered. + * + * Deliberately absent from default_options(), because "off" and "never set" + * have to stay distinguishable. Until the MCP section is saved once this + * stays null, and FrmMcpController::is_enabled() falls back to the API + * add-on's own toggle so a site that already turned MCP on there keeps it + * on. With no add-on setting to inherit either, MCP is off. + * + * @since x.x + * + * @var int|null 1 for enabled, 0 for disabled, null when never saved. + */ + public $mcp; + /** * @param array $args */ @@ -601,6 +616,16 @@ private function update_settings( $params ) { foreach ( $checkboxes as $set ) { $this->$set = isset( $params[ 'frm_' . $set ] ) ? absint( $params[ 'frm_' . $set ] ) : 0; } + + // The MCP toggle is kept out of the loop above on purpose. That loop turns + // every checkbox it does not find in $params off, and the MCP section is + // not always on screen: it is hidden entirely while the API add-on still + // owns the toggle. Saving any other section would switch MCP off. The + // section renders a marker field, so the value only moves when the toggle + // was really there to move it. + if ( ! empty( $params['frm_mcp_settings_shown'] ) ) { + $this->mcp = empty( $params['frm_mcp'] ) ? 0 : 1; + } } /** diff --git a/classes/models/FrmStyle.php b/classes/models/FrmStyle.php index 7cb8e3bb13..3a3570f0cd 100644 --- a/classes/models/FrmStyle.php +++ b/classes/models/FrmStyle.php @@ -527,19 +527,24 @@ private function clear_cache() { /** * Delete a style by its post ID. * - * @param int $id + * @param int|string $id The id of the style to delete. REST and MCP callers pass it as a string. * * @return false|WP_Post|null */ public function destroy( $id ) { - if ( $id === $this->get_default_style()->ID ) { + $default_style = $this->get_default_style(); + + // The ID arrives as a string from REST and MCP callers, so it is compared + // as an int. A site with no default style set has nothing to protect. + if ( $default_style && (int) $id === $default_style->ID ) { return false; } + return wp_delete_post( $id ); } /** - * @return stdClass|WP_Post + * @return stdClass|WP_Post|null Null when the style does not exist, or when no default style is set. */ public function get_one() { if ( 'default' === $this->id ) { diff --git a/classes/views/frm-settings/mcp.php b/classes/views/frm-settings/mcp.php new file mode 100644 index 0000000000..33f6074e48 --- /dev/null +++ b/classes/views/frm-settings/mcp.php @@ -0,0 +1,237 @@ + +

+ + + + +

+ + + + +
+ true, + 'value' => 1, + 'checked' => $mcp_enabled, + 'input_html' => array( + // This is what shows and hides div.frm_mcp_options below. The handler in + // admin.js is delegated on input[data-toggleclass], and the toggle's real + // checkbox is still an input, so it keeps firing from inside the toggle. + 'data-toggleclass' => 'frm_mcp_options', + ), + ) + ); + ?> + + 'frm-leading-none' ) + ); + ?> +
+ + +

+ +

+ + + +

+ +

+ + + +
+
+

+ + + + +

+

+ +

+
+ + 'true' ) ); ?> + + + + + ' . $version . ''; + } + } else { + $version = esc_html( $your_version ); + } + + // The version carries a link, so it is escaped as post HTML while the + // rest of the line is escaped as text. + if ( $skill_is_stale && $released_date ) { + printf( + /* translators: %1$s: The version of the skill that is available. %2$s: How long ago it was released, like "today". %3$s: The version this user downloaded. */ + esc_html__( '%1$s released %2$s — you have %3$s', 'formidable' ), + wp_kses_post( $version ), + esc_html( $released_date ), + esc_html( $your_version ) + ); + } elseif ( $skill_is_stale ) { + printf( + /* translators: %1$s: The version of the skill that is available. %2$s: The version this user downloaded. */ + esc_html__( '%1$s available — you have %2$s', 'formidable' ), + wp_kses_post( $version ), + esc_html( $your_version ) + ); + } elseif ( $version && $downloaded_date ) { + printf( + /* translators: %1$s: The version of the skill. %2$s: How long ago this user downloaded it, like "today". */ + esc_html__( '%1$s — downloaded %2$s', 'formidable' ), + wp_kses_post( $version ), + esc_html( $downloaded_date ) + ); + } elseif ( $version && $released_date ) { + printf( + /* translators: %1$s: The version of the skill. %2$s: How long ago it was released, like "today". */ + esc_html__( '%1$s released %2$s', 'formidable' ), + wp_kses_post( $version ), + esc_html( $released_date ) + ); + } else { + echo wp_kses_post( $version ); + }//end if + ?> + + +
+
+ +

+

+ +

+ + +

+ +

+ +

+

+ + + + + + + + + + + + + + + + + + +
+ + () + + + + + + +
    + +
  • + +
+ + — + +
+ +
diff --git a/lib/composer.json b/lib/composer.json new file mode 100644 index 0000000000..cc49dec63e --- /dev/null +++ b/lib/composer.json @@ -0,0 +1,16 @@ +{ + "name": "strategy11/formidable-forms-lib", + "description": "Autoloading for the Formidable Forms lib dependencies", + "type": "library", + "require": { + "wordpress/mcp-adapter": "0.6.1" + }, + "config": { + "platform": { + "php": "7.4" + }, + "allow-plugins": { + "automattic/jetpack-autoloader": false + } + } +} diff --git a/lib/vendor/autoload.php b/lib/vendor/autoload.php new file mode 100644 index 0000000000..9a549cb968 --- /dev/null +++ b/lib/vendor/autoload.php @@ -0,0 +1,25 @@ +. + + +=================================== + + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/lib/vendor/automattic/jetpack-autoloader/src/AutoloadFileWriter.php b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadFileWriter.php new file mode 100644 index 0000000000..c0e3368c7f --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadFileWriter.php @@ -0,0 +1,101 @@ + '../autoload_packages.php', + ); + $ignoreList = array( + 'AutoloadGenerator.php', + 'AutoloadProcessor.php', + 'CustomAutoloaderPlugin.php', + 'ManifestGenerator.php', + 'AutoloadFileWriter.php', + ); + + // Copy all of the autoloader files. + $files = scandir( __DIR__ ); + foreach ( $files as $file ) { + // Only PHP files will be copied. + if ( substr( $file, -4 ) !== '.php' ) { + continue; + } + + if ( in_array( $file, $ignoreList, true ) ) { + continue; + } + + $newFile = $renameList[ $file ] ?? $file; + $content = self::prepareAutoloaderFile( $file, $suffix ); + + $written = file_put_contents( $outDir . '/' . $newFile, $content ); + if ( $io ) { + if ( $written ) { + $io->writeError( " Generated: $newFile" ); + } else { + $io->writeError( " Error: $newFile" ); + } + } + } + } + + /** + * Prepares an autoloader file to be written to the destination. + * + * @param String $filename a file to prepare. + * @param String $suffix Unique suffix used in the namespace. + * + * @return string + */ + private static function prepareAutoloaderFile( $filename, $suffix ) { + $header = self::COMMENT; + $header .= PHP_EOL; + if ( $suffix === 'Current' ) { + // Unit testing. + $header .= 'namespace Automattic\Jetpack\Autoloader\jpCurrent;'; + } else { + $header .= 'namespace Automattic\Jetpack\Autoloader\jp' . $suffix . '\al' . preg_replace( '/[^0-9a-zA-Z]/', '_', AutoloadGenerator::VERSION ) . ';'; + } + $header .= PHP_EOL . PHP_EOL; + + $sourceLoader = fopen( __DIR__ . '/' . $filename, 'r' ); + $file_contents = stream_get_contents( $sourceLoader ); + return str_replace( + '/* HEADER */', + $header, + $file_contents + ); + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/AutoloadGenerator.php b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadGenerator.php new file mode 100644 index 0000000000..77b0325400 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadGenerator.php @@ -0,0 +1,403 @@ +io = $io; + $this->filesystem = new Filesystem(); + } + + /** + * Dump the Jetpack autoloader files. + * + * @param Composer $composer The Composer object. + * @param Config $config Config object. + * @param InstalledRepositoryInterface $localRepo Installed Repository object. + * @param PackageInterface $mainPackage Main Package object. + * @param InstallationManager $installationManager Manager for installing packages. + * @param string $targetDir Path to the current target directory. + * @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap. + * @param string $suffix The autoloader suffix. + */ + public function dump( + Composer $composer, + Config $config, + InstalledRepositoryInterface $localRepo, + PackageInterface $mainPackage, + InstallationManager $installationManager, + $targetDir, + $scanPsrPackages = false, + $suffix = null + ) { + $this->filesystem->ensureDirectoryExists( $config->get( 'vendor-dir' ) ); + + $packageMap = $composer->getAutoloadGenerator()->buildPackageMap( $installationManager, $mainPackage, $localRepo->getCanonicalPackages() ); + $autoloads = $this->parseAutoloads( $packageMap, $mainPackage ); + + // Convert the autoloads into a format that the manifest generator can consume more easily. + $basePath = $this->filesystem->normalizePath( realpath( getcwd() ) ); + $vendorPath = $this->filesystem->normalizePath( realpath( $config->get( 'vendor-dir' ) ) ); + $processedAutoloads = $this->processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath ); + unset( $packageMap, $autoloads ); + + // Make sure none of the legacy files remain that can lead to problems with the autoloader. + $this->removeLegacyFiles( $vendorPath ); + + // Write all of the files now that we're done. + $this->writeAutoloaderFiles( $vendorPath . '/jetpack-autoloader/', $suffix ); + $this->writeManifests( $vendorPath . '/' . $targetDir, $processedAutoloads ); + + if ( ! $scanPsrPackages ) { + $this->io->writeError( 'You are generating an unoptimized autoloader. If this is a production build, consider using the -o option.' ); + } + } + + /** + * Compiles an ordered list of namespace => path mappings + * + * @param array $packageMap Array of array(package, installDir-relative-to-composer.json). + * @param PackageInterface $mainPackage Main package instance. + * + * @return array The list of path mappings. + */ + public function parseAutoloads( array $packageMap, PackageInterface $mainPackage ) { + $rootPackageMap = array_shift( $packageMap ); + + $sortedPackageMap = $this->sortPackageMap( $packageMap ); + $sortedPackageMap[] = $rootPackageMap; + array_unshift( $packageMap, $rootPackageMap ); + + $psr0 = $this->parseAutoloadsType( $packageMap, 'psr-0', $mainPackage ); + $psr4 = $this->parseAutoloadsType( $packageMap, 'psr-4', $mainPackage ); + $classmap = $this->parseAutoloadsType( array_reverse( $sortedPackageMap ), 'classmap', $mainPackage ); + $files = $this->parseAutoloadsType( $sortedPackageMap, 'files', $mainPackage ); + + krsort( $psr0 ); + krsort( $psr4 ); + + return array( + 'psr-0' => $psr0, + 'psr-4' => $psr4, + 'classmap' => $classmap, + 'files' => $files, + ); + } + + /** + * Sorts packages by dependency weight + * + * Packages of equal weight retain the original order + * + * @param array $packageMap The package map. + * + * @return array + */ + protected function sortPackageMap( array $packageMap ) { + $packages = array(); + $paths = array(); + + foreach ( $packageMap as $item ) { + list( $package, $path ) = $item; + $name = $package->getName(); + $packages[ $name ] = $package; + $paths[ $name ] = $path; + } + + $sortedPackages = PackageSorter::sortPackages( $packages ); + + $sortedPackageMap = array(); + + foreach ( $sortedPackages as $package ) { + $name = $package->getName(); + $sortedPackageMap[] = array( $packages[ $name ], $paths[ $name ] ); + } + + return $sortedPackageMap; + } + + /** + * Returns the file identifier. + * + * @param PackageInterface $package The package instance. + * @param string $path The path. + */ + protected function getFileIdentifier( PackageInterface $package, $path ) { + return md5( $package->getName() . ':' . $path ); + } + + /** + * Returns the path code for the given path. + * + * @param Filesystem $filesystem The filesystem instance. + * @param string $basePath The base path. + * @param string $vendorPath The vendor path. + * @param string $path The path. + * + * @return string The path code. + */ + protected function getPathCode( Filesystem $filesystem, $basePath, $vendorPath, $path ) { + if ( ! $filesystem->isAbsolutePath( $path ) ) { + $path = $basePath . '/' . $path; + } + $path = $filesystem->normalizePath( $path ); + + $baseDir = ''; + if ( 0 === strpos( $path . '/', $vendorPath . '/' ) ) { + $path = substr( $path, strlen( $vendorPath ) ); + $baseDir = '$vendorDir'; + + if ( false !== $path ) { + $baseDir .= ' . '; + } + } else { + $path = $filesystem->normalizePath( $filesystem->findShortestPath( $basePath, $path, true ) ); + if ( ! $filesystem->isAbsolutePath( $path ) ) { + $baseDir = '$baseDir . '; + $path = '/' . $path; + } + } + + if ( strpos( $path, '.phar' ) !== false ) { + $baseDir = "'phar://' . " . $baseDir; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + return $baseDir . ( ( false !== $path ) ? var_export( $path, true ) : '' ); + } + + /** + * This function differs from the composer parseAutoloadsType in that beside returning the path. + * It also return the path and the version of a package. + * + * Supports PSR-4, PSR-0, and classmap parsing. + * + * @param array $packageMap Map of all the packages. + * @param string $type Type of autoloader to use. + * @param PackageInterface $mainPackage Instance of the Package Object. + * + * @return array + */ + protected function parseAutoloadsType( array $packageMap, $type, PackageInterface $mainPackage ) { + $autoloads = array(); + + foreach ( $packageMap as $item ) { + list($package, $installPath) = $item; + $autoload = $package->getAutoload(); + $version = $package->getVersion(); // Version of the class comes from the package - should we try to parse it? + + // Store our own actual package version, not "dev-trunk" or whatever. + if ( $package->getName() === 'automattic/jetpack-autoloader' ) { + $version = self::VERSION; + } + + if ( $package === $mainPackage ) { + $autoload = array_merge_recursive( $autoload, $package->getDevAutoload() ); + } + + if ( null !== $package->getTargetDir() && $package !== $mainPackage ) { + $installPath = substr( $installPath, 0, -strlen( '/' . $package->getTargetDir() ) ); + } + + if ( in_array( $type, array( 'psr-4', 'psr-0' ), true ) && isset( $autoload[ $type ] ) && is_array( $autoload[ $type ] ) ) { + foreach ( $autoload[ $type ] as $namespace => $paths ) { + $paths = is_array( $paths ) ? $paths : array( $paths ); + foreach ( $paths as $path ) { + $relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path; + $autoloads[ $namespace ][] = array( + 'path' => $relativePath, + 'version' => $version, + ); + } + } + } + + if ( 'classmap' === $type && isset( $autoload['classmap'] ) && is_array( $autoload['classmap'] ) ) { + foreach ( $autoload['classmap'] as $paths ) { + $paths = is_array( $paths ) ? $paths : array( $paths ); + foreach ( $paths as $path ) { + $relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path; + $autoloads[] = array( + 'path' => $relativePath, + 'version' => $version, + ); + } + } + } + if ( 'files' === $type && isset( $autoload['files'] ) && is_array( $autoload['files'] ) ) { + foreach ( $autoload['files'] as $paths ) { + $paths = is_array( $paths ) ? $paths : array( $paths ); + foreach ( $paths as $path ) { + $relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path; + $autoloads[ $this->getFileIdentifier( $package, $path ) ] = array( + 'path' => $relativePath, + 'version' => $version, + ); + } + } + } + } + + return $autoloads; + } + + /** + * Given Composer's autoloads this will convert them to a version that we can use to generate the manifests. + * + * When the $scanPsrPackages argument is true, PSR-4 namespaces are converted to classmaps. When $scanPsrPackages + * is false, PSR-4 namespaces are not converted to classmaps. + * + * PSR-0 namespaces are always converted to classmaps. + * + * @param array $autoloads The autoloads we want to process. + * @param bool $scanPsrPackages Whether or not PSR-4 packages should be converted to a classmap. + * @param string $vendorPath The path to the vendor directory. + * @param string $basePath The path to the current directory. + * + * @return array $processedAutoloads + */ + private function processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath ) { + $processor = new AutoloadProcessor( + function ( $path, $excludedClasses, $namespace ) use ( $basePath ) { + $dir = $this->filesystem->normalizePath( + $this->filesystem->isAbsolutePath( $path ) ? $path : $basePath . '/' . $path + ); + + // Composer 2.4 changed the name of the class. + if ( class_exists( \Composer\ClassMapGenerator\ClassMapGenerator::class ) ) { + if ( ! is_dir( $dir ) && ! is_file( $dir ) ) { + return array(); + } + $generator = new \Composer\ClassMapGenerator\ClassMapGenerator(); + $generator->scanPaths( $dir, $excludedClasses, 'classmap', empty( $namespace ) ? null : $namespace ); + return $generator->getClassMap()->getMap(); + } + + return \Composer\Autoload\ClassMapGenerator::createMap( + $dir, + $excludedClasses, + null, // Don't pass the IOInterface since the normal autoload generation will have reported already. + empty( $namespace ) ? null : $namespace + ); + }, + function ( $path ) use ( $basePath, $vendorPath ) { + return $this->getPathCode( $this->filesystem, $basePath, $vendorPath, $path ); + } + ); + + return array( + 'psr-4' => $processor->processPsr4Packages( $autoloads, $scanPsrPackages ), + 'classmap' => $processor->processClassmap( $autoloads, $scanPsrPackages ), + 'files' => $processor->processFiles( $autoloads ), + ); + } + + /** + * Removes all of the legacy autoloader files so they don't cause any problems. + * + * @param string $outDir The directory legacy files are written to. + */ + private function removeLegacyFiles( $outDir ) { + $files = array( + 'autoload_functions.php', + 'class-autoloader-handler.php', + 'class-classes-handler.php', + 'class-files-handler.php', + 'class-plugins-handler.php', + 'class-version-selector.php', + ); + foreach ( $files as $file ) { + $this->filesystem->remove( $outDir . '/' . $file ); + } + } + + /** + * Writes all of the autoloader files to disk. + * + * @param string $outDir The directory to write to. + * @param string $suffix The unique autoloader suffix. + */ + private function writeAutoloaderFiles( $outDir, $suffix ) { + $this->io->writeError( "Generating jetpack autoloader ($outDir)" ); + + // We will remove all autoloader files to generate this again. + $this->filesystem->emptyDirectory( $outDir ); + + // Write the autoloader files. + AutoloadFileWriter::copyAutoloaderFiles( $this->io, $outDir, $suffix ); + } + + /** + * Writes all of the manifest files to disk. + * + * @param string $outDir The directory to write to. + * @param array $processedAutoloads The processed autoloads. + */ + private function writeManifests( $outDir, $processedAutoloads ) { + $this->io->writeError( "Generating jetpack autoloader manifests ($outDir)" ); + + $manifestFiles = array( + 'classmap' => 'jetpack_autoload_classmap.php', + 'psr-4' => 'jetpack_autoload_psr4.php', + 'files' => 'jetpack_autoload_filemap.php', + ); + + foreach ( $manifestFiles as $key => $file ) { + // Make sure the file doesn't exist so it isn't there if we don't write it. + $this->filesystem->remove( $outDir . '/' . $file ); + if ( empty( $processedAutoloads[ $key ] ) ) { + continue; + } + + $content = ManifestGenerator::buildManifest( $key, $file, $processedAutoloads[ $key ] ); + if ( empty( $content ) ) { + continue; + } + + if ( file_put_contents( $outDir . '/' . $file, $content ) ) { + $this->io->writeError( " Generated: $file" ); + } else { + $this->io->writeError( " Error: $file" ); + } + } + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/AutoloadProcessor.php b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadProcessor.php new file mode 100644 index 0000000000..ca5ccd99bb --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/AutoloadProcessor.php @@ -0,0 +1,176 @@ +classmapScanner = $classmapScanner; + $this->pathCodeTransformer = $pathCodeTransformer; + } + + /** + * Processes the classmap autoloads into a relative path format including the version for each file. + * + * @param array $autoloads The autoloads we are processing. + * @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap. + * + * @return array|null $processed + * @phan-param array{classmap:?array{path:string,version:string}[],psr-4:?array,psr-0:?array} $autoloads + */ + public function processClassmap( $autoloads, $scanPsrPackages ) { + // We can't scan PSR packages if we don't actually have any. + if ( empty( $autoloads['psr-4'] ) ) { + $scanPsrPackages = false; + } + + if ( empty( $autoloads['classmap'] ) && ! $scanPsrPackages ) { + return null; + } + + $excludedClasses = null; + if ( ! empty( $autoloads['exclude-from-classmap'] ) ) { + $excludedClasses = '{(' . implode( '|', $autoloads['exclude-from-classmap'] ) . ')}'; + } + + $processed = array(); + + if ( $scanPsrPackages ) { + foreach ( $autoloads['psr-4'] as $namespace => $sources ) { + $namespace = empty( $namespace ) ? null : $namespace; + + foreach ( $sources as $source ) { + $classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace ); + + foreach ( $classmap as $class => $path ) { + $processed[ $class ] = array( + 'version' => $source['version'], + 'path' => call_user_func( $this->pathCodeTransformer, $path ), + ); + } + } + } + } + + /* + * PSR-0 namespaces are converted to classmaps for both optimized and unoptimized autoloaders because any new + * development should use classmap or PSR-4 autoloading. + */ + if ( ! empty( $autoloads['psr-0'] ) ) { + foreach ( $autoloads['psr-0'] as $namespace => $sources ) { + $namespace = empty( $namespace ) ? null : $namespace; + + foreach ( $sources as $source ) { + $classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace ); + foreach ( $classmap as $class => $path ) { + $processed[ $class ] = array( + 'version' => $source['version'], + 'path' => call_user_func( $this->pathCodeTransformer, $path ), + ); + } + } + } + } + + if ( ! empty( $autoloads['classmap'] ) ) { + foreach ( $autoloads['classmap'] as $package ) { + $classmap = call_user_func( $this->classmapScanner, $package['path'], $excludedClasses, null ); + + foreach ( $classmap as $class => $path ) { + $processed[ $class ] = array( + 'version' => $package['version'], + 'path' => call_user_func( $this->pathCodeTransformer, $path ), + ); + } + } + } + + ksort( $processed ); + + return $processed; + } + + /** + * Processes the PSR-4 autoloads into a relative path format including the version for each file. + * + * @param array $autoloads The autoloads we are processing. + * @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap. + * + * @return array|null $processed + */ + public function processPsr4Packages( $autoloads, $scanPsrPackages ) { + if ( $scanPsrPackages || empty( $autoloads['psr-4'] ) ) { + return null; + } + + $processed = array(); + + foreach ( $autoloads['psr-4'] as $namespace => $packages ) { + $namespace = empty( $namespace ) ? null : $namespace; + $paths = array(); + + foreach ( $packages as $package ) { + $paths[] = call_user_func( $this->pathCodeTransformer, $package['path'] ); + } + + $processed[ $namespace ] = array( + 'version' => $package['version'], + 'path' => $paths, + ); + } + + return $processed; + } + + /** + * Processes the file autoloads into a relative format including the version for each file. + * + * @param array $autoloads The autoloads we are processing. + * + * @return array|null $processed + */ + public function processFiles( $autoloads ) { + if ( empty( $autoloads['files'] ) ) { + return null; + } + + $processed = array(); + + foreach ( $autoloads['files'] as $file_id => $package ) { + $processed[ $file_id ] = array( + 'version' => $package['version'], + 'path' => call_user_func( $this->pathCodeTransformer, $package['path'] ), + ); + } + + return $processed; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/CustomAutoloaderPlugin.php b/lib/vendor/automattic/jetpack-autoloader/src/CustomAutoloaderPlugin.php new file mode 100644 index 0000000000..475570837b --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/CustomAutoloaderPlugin.php @@ -0,0 +1,188 @@ +composer = $composer; + $this->io = $io; + } + + /** + * Do nothing. + * phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + * + * @param Composer $composer Composer object. + * @param IOInterface $io IO object. + */ + public function deactivate( Composer $composer, IOInterface $io ) { + /* + * Intentionally left empty. This is a PluginInterface method. + * phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + */ + } + + /** + * Do nothing. + * phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + * + * @param Composer $composer Composer object. + * @param IOInterface $io IO object. + */ + public function uninstall( Composer $composer, IOInterface $io ) { + /* + * Intentionally left empty. This is a PluginInterface method. + * phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + */ + } + + /** + * Tell composer to listen for events and do something with them. + * + * @return array List of subscribed events. + */ + public static function getSubscribedEvents() { + return array( + ScriptEvents::POST_AUTOLOAD_DUMP => 'postAutoloadDump', + ); + } + + /** + * Generate the custom autolaoder. + * + * @param Event $event Script event object. + */ + public function postAutoloadDump( Event $event ) { + // When the autoloader is not required by the root package we don't want to execute it. + // This prevents unwanted transitive execution that generates unused autoloaders or + // at worst throws fatal executions. + if ( ! $this->isRequiredByRoot() ) { + return; + } + + $config = $this->composer->getConfig(); + + if ( 'vendor' !== $config->raw()['config']['vendor-dir'] ) { + $this->io->writeError( "\nAn error occurred while generating the autoloader files:", true ); + $this->io->writeError( 'The project\'s composer.json or composer environment set a non-default vendor directory.', true ); + $this->io->writeError( 'The default composer vendor directory must be used.', true ); + exit( 0 ); + } + + $installationManager = $this->composer->getInstallationManager(); + $repoManager = $this->composer->getRepositoryManager(); + $localRepo = $repoManager->getLocalRepository(); + $package = $this->composer->getPackage(); + $optimize = $event->getFlags()['optimize']; + $suffix = $this->determineSuffix(); + + $generator = new AutoloadGenerator( $this->io ); + $generator->dump( $this->composer, $config, $localRepo, $package, $installationManager, 'composer', $optimize, $suffix ); + } + + /** + * Determine the suffix for the autoloader class. + * + * Reuses an existing suffix from vendor/autoload_packages.php or vendor/autoload.php if possible. + * + * @return string Suffix. + */ + private function determineSuffix() { + $config = $this->composer->getConfig(); + $vendorPath = $config->get( 'vendor-dir' ); + + // Command line. + $suffix = $config->get( 'autoloader-suffix' ); + if ( $suffix ) { + return $suffix; + } + + // Reuse our own suffix, if any. + if ( is_readable( $vendorPath . '/autoload_packages.php' ) ) { + $content = file_get_contents( $vendorPath . '/autoload_packages.php' ); + if ( preg_match( '/^namespace Automattic\\\\Jetpack\\\\Autoloader\\\\jp([^;\s]+?)(?:\\\\al[^;\s]+)?;/m', $content, $match ) ) { + return $match[1]; + } + } + + // Reuse Composer's suffix, if any. + if ( is_readable( $vendorPath . '/autoload.php' ) ) { + $content = file_get_contents( $vendorPath . '/autoload.php' ); + if ( preg_match( '{ComposerAutoloaderInit([^:\s]+)::}', $content, $match ) ) { + return $match[1]; + } + } + + // Generate a random suffix. + return md5( uniqid( '', true ) ); + } + + /** + * Checks to see whether or not the root package is the one that required the autoloader. + * + * @return bool + */ + private function isRequiredByRoot() { + $package = $this->composer->getPackage(); + $requires = $package->getRequires(); + if ( ! is_array( $requires ) ) { // @phan-suppress-current-line PhanRedundantCondition -- Earlier Composer versions may not have guaranteed this. + $requires = array(); + } + $devRequires = $package->getDevRequires(); + if ( ! is_array( $devRequires ) ) { // @phan-suppress-current-line PhanRedundantCondition -- Earlier Composer versions may not have guaranteed this. + $devRequires = array(); + } + $requires = array_merge( $requires, $devRequires ); + + if ( empty( $requires ) ) { + $this->io->writeError( "\nThe package is not required and this should never happen?", true ); + exit( 0 ); + } + + foreach ( $requires as $require ) { + if ( 'automattic/jetpack-autoloader' === $require->getTarget() ) { + return true; + } + } + + return false; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/ManifestGenerator.php b/lib/vendor/automattic/jetpack-autoloader/src/ManifestGenerator.php new file mode 100644 index 0000000000..254d46a591 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/ManifestGenerator.php @@ -0,0 +1,115 @@ + $data ) { + $key = var_export( $key, true ); + $versionCode = var_export( $data['version'], true ); + $fileContent .= << array( + 'version' => $versionCode, + 'path' => {$data['path']} + ), +MANIFEST_CODE; + $fileContent .= PHP_EOL; + } + + return self::buildFile( $fileName, $fileContent ); + } + + /** + * Builds the contents for the PSR-4 manifest file. + * + * @param string $fileName The filename we are building. + * @param array $namespaces The formatted PSR-4 data for the manifest. + * + * @return string|null $manifestFile + */ + private static function buildPsr4Manifest( $fileName, $namespaces ) { + $fileContent = PHP_EOL; + foreach ( $namespaces as $namespace => $data ) { + $namespaceCode = var_export( $namespace, true ); + $versionCode = var_export( $data['version'], true ); + $pathCode = 'array( ' . implode( ', ', $data['path'] ) . ' )'; + $fileContent .= << array( + 'version' => $versionCode, + 'path' => $pathCode + ), +MANIFEST_CODE; + $fileContent .= PHP_EOL; + } + + return self::buildFile( $fileName, $fileContent ); + } + + /** + * Generate the PHP that will be used in the file. + * + * @param string $fileName The filename we are building. + * @param string $content The content to be written into the file. + * + * @return string $fileContent + */ + private static function buildFile( $fileName, $content ) { + return <<php_autoloader = $php_autoloader; + $this->hook_manager = $hook_manager; + $this->manifest_reader = $manifest_reader; + $this->version_selector = $version_selector; + } + + /** + * Checks to see whether or not an autoloader is currently in the process of initializing. + * + * @return bool + */ + public function is_initializing() { + // If no version has been set it means that no autoloader has started initializing yet. + global $jetpack_autoloader_latest_version; + if ( ! isset( $jetpack_autoloader_latest_version ) ) { + return false; + } + + // When the version is set but the classmap is not it ALWAYS means that this is the + // latest autoloader and is being included by an older one. + global $jetpack_packages_classmap; + if ( empty( $jetpack_packages_classmap ) ) { + return true; + } + + // Version 2.4.0 added a new global and altered the reset semantics. We need to check + // the other global as well since it may also point at initialization. + // Note: We don't need to check for the class first because every autoloader that + // will set the latest version global requires this class in the classmap. + $replacing_version = $jetpack_packages_classmap[ AutoloadGenerator::class ]['version']; + if ( $this->version_selector->is_dev_version( $replacing_version ) || version_compare( $replacing_version, '2.4.0.0', '>=' ) ) { + global $jetpack_autoloader_loader; + if ( ! isset( $jetpack_autoloader_loader ) ) { + return true; + } + } + + return false; + } + + /** + * Activates an autoloader using the given plugins and activates it. + * + * @param string[] $plugins The plugins to initialize the autoloader for. + */ + public function activate_autoloader( $plugins ) { + global $jetpack_packages_psr4; + $jetpack_packages_psr4 = array(); + $this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_psr4.php', $jetpack_packages_psr4 ); + + global $jetpack_packages_classmap; + $jetpack_packages_classmap = array(); + $this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_classmap.php', $jetpack_packages_classmap ); + + global $jetpack_packages_filemap; + $jetpack_packages_filemap = array(); + $this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_filemap.php', $jetpack_packages_filemap ); + + $loader = new Version_Loader( + $this->version_selector, + $jetpack_packages_classmap, + $jetpack_packages_psr4, + $jetpack_packages_filemap + ); + + $this->php_autoloader->register_autoloader( $loader ); + + // Now that the autoloader is active we can load the filemap. + $loader->load_filemap(); + } + + /** + * Resets the active autoloader and all related global state. + */ + public function reset_autoloader() { + $this->php_autoloader->unregister_autoloader(); + $this->hook_manager->reset(); + + // Clear all of the autoloader globals so that older autoloaders don't do anything strange. + global $jetpack_autoloader_latest_version; + $jetpack_autoloader_latest_version = null; + + global $jetpack_packages_classmap; + $jetpack_packages_classmap = array(); // Must be array to avoid exceptions in old autoloaders! + + global $jetpack_packages_psr4; + $jetpack_packages_psr4 = array(); // Must be array to avoid exceptions in old autoloaders! + + global $jetpack_packages_filemap; + $jetpack_packages_filemap = array(); // Must be array to avoid exceptions in old autoloaders! + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader-locator.php b/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader-locator.php new file mode 100644 index 0000000000..908222601c --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader-locator.php @@ -0,0 +1,82 @@ +version_selector = $version_selector; + } + + /** + * Finds the path to the plugin with the latest autoloader. + * + * @param array $plugin_paths An array of plugin paths. + * @param string $latest_version The latest version reference. @phan-output-reference. + * + * @return string|null + */ + public function find_latest_autoloader( $plugin_paths, &$latest_version ) { + $latest_plugin = null; + + foreach ( $plugin_paths as $plugin_path ) { + $version = $this->get_autoloader_version( $plugin_path ); + if ( ! $version || ! $this->version_selector->is_version_update_required( $latest_version, $version ) ) { + continue; + } + + $latest_version = $version; + $latest_plugin = $plugin_path; + } + + return $latest_plugin; + } + + /** + * Gets the path to the autoloader. + * + * @param string $plugin_path The path to the plugin. + * + * @return string + */ + public function get_autoloader_path( $plugin_path ) { + return trailingslashit( $plugin_path ) . 'vendor/autoload_packages.php'; + } + + /** + * Gets the version for the autoloader. + * + * @param string $plugin_path The path to the plugin. + * + * @return string|null + */ + public function get_autoloader_version( $plugin_path ) { + $classmap = trailingslashit( $plugin_path ) . 'vendor/composer/jetpack_autoload_classmap.php'; + if ( ! file_exists( $classmap ) ) { + return null; + } + + $classmap = require $classmap; + if ( isset( $classmap[ AutoloadGenerator::class ] ) ) { + return $classmap[ AutoloadGenerator::class ]['version']; + } + + return null; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader.php b/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader.php new file mode 100644 index 0000000000..c9b66080a3 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-autoloader.php @@ -0,0 +1,85 @@ +get( Autoloader_Handler::class ); + + // If the autoloader is already initializing it means that it has included us as the latest. + $was_included_by_autoloader = $autoloader_handler->is_initializing(); + + /** @var Plugin_Locator $plugin_locator */ + $plugin_locator = $container->get( Plugin_Locator::class ); + + /** @var Plugins_Handler $plugins_handler */ + $plugins_handler = $container->get( Plugins_Handler::class ); + + // The current plugin is the one that we are attempting to initialize here. + $current_plugin = $plugin_locator->find_current_plugin(); + + // The active plugins are those that we were able to discover on the site. This list will not + // include mu-plugins, those activated by code, or those who are hidden by filtering. We also + // want to take care to not consider the current plugin unknown if it was included by an + // autoloader. This avoids the case where a plugin will be marked "active" while deactivated + // due to it having the latest autoloader. + $active_plugins = $plugins_handler->get_active_plugins( true, ! $was_included_by_autoloader ); + + // The cached plugins are all of those that were active or discovered by the autoloader during a previous request. + // Note that it's possible this list will include plugins that have since been deactivated, but after a request + // the cache should be updated and the deactivated plugins will be removed. + $cached_plugins = $plugins_handler->get_cached_plugins(); + + // We combine the active list and cached list to preemptively load classes for plugins that are + // presently unknown but will be loaded during the request. While this may result in us considering packages in + // deactivated plugins there shouldn't be any problems as a result and the eventual consistency is sufficient. + $all_plugins = array_merge( $active_plugins, $cached_plugins ); + + // In particular we also include the current plugin to address the case where it is the latest autoloader + // but also unknown (and not cached). We don't want it in the active list because we don't know that it + // is active but we need it in the all plugins list so that it is considered by the autoloader. + $all_plugins[] = $current_plugin; + + // We require uniqueness in the array to avoid processing the same plugin more than once. + $all_plugins = array_values( array_unique( $all_plugins ) ); + + /** @var Latest_Autoloader_Guard $guard */ + $guard = $container->get( Latest_Autoloader_Guard::class ); + if ( $guard->should_stop_init( $current_plugin, $all_plugins, $was_included_by_autoloader ) ) { + return; + } + + // Initialize the autoloader using the handler now that we're ready. + $autoloader_handler->activate_autoloader( $all_plugins ); + + /** @var Hook_Manager $hook_manager */ + $hook_manager = $container->get( Hook_Manager::class ); + + // Register a shutdown handler to clean up the autoloader. + $hook_manager->add_action( 'shutdown', new Shutdown_Handler( $plugins_handler, $cached_plugins, $was_included_by_autoloader ) ); + + // Register a plugins_loaded handler to check for conflicting autoloaders. + $hook_manager->add_action( 'plugins_loaded', array( $guard, 'check_for_conflicting_autoloaders' ), 1 ); + + // phpcs:enable Generic.Commenting.DocComment.MissingShort + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-container.php b/lib/vendor/automattic/jetpack-autoloader/src/class-container.php new file mode 100644 index 0000000000..19ea95cb0e --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-container.php @@ -0,0 +1,142 @@ + 'Hook_Manager', + ); + + /** + * A map of all the dependencies we've registered with the container and created. + * + * @var array + */ + protected $dependencies; + + /** + * The constructor. + */ + public function __construct() { + $this->dependencies = array(); + + $this->register_shared_dependencies(); + $this->register_dependencies(); + $this->initialize_globals(); + } + + /** + * Gets a dependency out of the container. + * + * @param string $class The class to fetch. + * + * @return mixed + * @throws \InvalidArgumentException When a class that isn't registered with the container is fetched. + */ + public function get( $class ) { + if ( ! isset( $this->dependencies[ $class ] ) ) { + throw new \InvalidArgumentException( "Class '$class' is not registered with the container." ); + } + + return $this->dependencies[ $class ]; + } + + /** + * Registers all of the dependencies that are shared between all instances of the autoloader. + */ + private function register_shared_dependencies() { + global $jetpack_autoloader_container_shared; + if ( ! isset( $jetpack_autoloader_container_shared ) ) { + $jetpack_autoloader_container_shared = array(); + } + + $key = self::SHARED_DEPENDENCY_KEYS[ Hook_Manager::class ]; + if ( ! isset( $jetpack_autoloader_container_shared[ $key ] ) ) { + require_once __DIR__ . '/class-hook-manager.php'; + $jetpack_autoloader_container_shared[ $key ] = new Hook_Manager(); + } + $this->dependencies[ Hook_Manager::class ] = &$jetpack_autoloader_container_shared[ $key ]; + } + + /** + * Registers all of the dependencies with the container. + */ + private function register_dependencies() { + require_once __DIR__ . '/class-path-processor.php'; + $this->dependencies[ Path_Processor::class ] = new Path_Processor(); + + require_once __DIR__ . '/class-plugin-locator.php'; + $this->dependencies[ Plugin_Locator::class ] = new Plugin_Locator( + $this->get( Path_Processor::class ) + ); + + require_once __DIR__ . '/class-version-selector.php'; + $this->dependencies[ Version_Selector::class ] = new Version_Selector(); + + require_once __DIR__ . '/class-autoloader-locator.php'; + $this->dependencies[ Autoloader_Locator::class ] = new Autoloader_Locator( + $this->get( Version_Selector::class ) + ); + + require_once __DIR__ . '/class-php-autoloader.php'; + $this->dependencies[ PHP_Autoloader::class ] = new PHP_Autoloader(); + + require_once __DIR__ . '/class-manifest-reader.php'; + $this->dependencies[ Manifest_Reader::class ] = new Manifest_Reader( + $this->get( Version_Selector::class ) + ); + + require_once __DIR__ . '/class-plugins-handler.php'; + $this->dependencies[ Plugins_Handler::class ] = new Plugins_Handler( + $this->get( Plugin_Locator::class ), + $this->get( Path_Processor::class ) + ); + + require_once __DIR__ . '/class-autoloader-handler.php'; + $this->dependencies[ Autoloader_Handler::class ] = new Autoloader_Handler( + $this->get( PHP_Autoloader::class ), + $this->get( Hook_Manager::class ), + $this->get( Manifest_Reader::class ), + $this->get( Version_Selector::class ) + ); + + require_once __DIR__ . '/class-latest-autoloader-guard.php'; + $this->dependencies[ Latest_Autoloader_Guard::class ] = new Latest_Autoloader_Guard( + $this->get( Plugins_Handler::class ), + $this->get( Autoloader_Handler::class ), + $this->get( Autoloader_Locator::class ) + ); + + // Register any classes that we will use elsewhere. + require_once __DIR__ . '/class-version-loader.php'; + require_once __DIR__ . '/class-shutdown-handler.php'; + } + + /** + * Initializes any of the globals needed by the autoloader. + */ + private function initialize_globals() { + /* + * This global was retired in version 2.9. The value is set to 'false' to maintain + * compatibility with older versions of the autoloader. + */ + global $jetpack_autoloader_including_latest; + $jetpack_autoloader_including_latest = false; + + // Not all plugins can be found using the locator. In cases where a plugin loads the autoloader + // but was not discoverable, we will record them in this array to track them as "active". + global $jetpack_autoloader_activating_plugins_paths; + if ( ! isset( $jetpack_autoloader_activating_plugins_paths ) ) { + $jetpack_autoloader_activating_plugins_paths = array(); + } + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-hook-manager.php b/lib/vendor/automattic/jetpack-autoloader/src/class-hook-manager.php new file mode 100644 index 0000000000..1d64c756bf --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-hook-manager.php @@ -0,0 +1,68 @@ +registered_hooks = array(); + } + + /** + * Adds an action to WordPress and registers it internally. + * + * @param string $tag The name of the action which is hooked. + * @param callable $callable The function to call. + * @param int $priority Used to specify the priority of the action. + * @param int $accepted_args Used to specify the number of arguments the callable accepts. + */ + public function add_action( $tag, $callable, $priority = 10, $accepted_args = 1 ) { + $this->registered_hooks[ $tag ][] = array( + 'priority' => $priority, + 'callable' => $callable, + ); + + add_action( $tag, $callable, $priority, $accepted_args ); + } + + /** + * Adds a filter to WordPress and registers it internally. + * + * @param string $tag The name of the filter which is hooked. + * @param callable $callable The function to call. + * @param int $priority Used to specify the priority of the filter. + * @param int $accepted_args Used to specify the number of arguments the callable accepts. + */ + public function add_filter( $tag, $callable, $priority = 10, $accepted_args = 1 ) { + $this->registered_hooks[ $tag ][] = array( + 'priority' => $priority, + 'callable' => $callable, + ); + + add_filter( $tag, $callable, $priority, $accepted_args ); + } + + /** + * Removes all of the registered hooks. + */ + public function reset() { + foreach ( $this->registered_hooks as $tag => $hooks ) { + foreach ( $hooks as $hook ) { + remove_filter( $tag, $hook['callable'], $hook['priority'] ); + } + } + $this->registered_hooks = array(); + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-latest-autoloader-guard.php b/lib/vendor/automattic/jetpack-autoloader/src/class-latest-autoloader-guard.php new file mode 100644 index 0000000000..285fe739a4 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-latest-autoloader-guard.php @@ -0,0 +1,157 @@ +plugins_handler = $plugins_handler; + $this->autoloader_handler = $autoloader_handler; + $this->autoloader_locator = $autoloader_locator; + } + + /** + * Indicates whether or not the autoloader should be initialized. Note that this function + * has the side-effect of actually loading the latest autoloader in the event that this + * is not it. + * + * @param string $current_plugin The current plugin we're checking. + * @param string[] $plugins The active plugins to check for autoloaders in. + * @param bool $was_included_by_autoloader Indicates whether or not this autoloader was included by another. + * + * @return bool True if we should stop initialization, otherwise false. + */ + public function should_stop_init( $current_plugin, $plugins, $was_included_by_autoloader ) { + global $jetpack_autoloader_latest_version; + + // We need to reset the autoloader when the plugins change because + // that means the autoloader was generated with a different list. + if ( $this->plugins_handler->have_plugins_changed( $plugins ) ) { + $this->autoloader_handler->reset_autoloader(); + } + + // When the latest autoloader has already been found we don't need to search for it again. + // We should take care however because this will also trigger if the autoloader has been + // included by an older one. + if ( isset( $jetpack_autoloader_latest_version ) && ! $was_included_by_autoloader ) { + return true; + } + + $latest_plugin = $this->autoloader_locator->find_latest_autoloader( $plugins, $jetpack_autoloader_latest_version ); + if ( isset( $latest_plugin ) && $latest_plugin !== $current_plugin ) { + require $this->autoloader_locator->get_autoloader_path( $latest_plugin ); + return true; + } + + return false; + } + + /** + * Check for conflicting autoloaders. + * + * A common source of strange and confusing problems is when another plugin + * registers a Composer autoloader at a higher priority that us. If enabled, + * check for this problem and warn about it. + * + * Called from the plugins_loaded hook. + * + * @since 3.1.0 + * @return void + */ + public function check_for_conflicting_autoloaders() { + if ( ! defined( 'JETPACK_AUTOLOAD_DEBUG_CONFLICTING_LOADERS' ) || ! JETPACK_AUTOLOAD_DEBUG_CONFLICTING_LOADERS ) { + return; + } + + global $jetpack_autoloader_loader; + if ( ! isset( $jetpack_autoloader_loader ) ) { + return; + } + $prefixes = array(); + foreach ( ( $jetpack_autoloader_loader->get_class_map() ?? array() ) as $classname => $data ) { + $parts = explode( '\\', trim( $classname, '\\' ) ); + array_pop( $parts ); + while ( $parts ) { + $prefixes[ implode( '\\', $parts ) . '\\' ] = true; + array_pop( $parts ); + } + } + foreach ( ( $jetpack_autoloader_loader->get_psr4_map() ?? array() ) as $prefix => $data ) { + $parts = explode( '\\', trim( $prefix, '\\' ) ); + while ( $parts ) { + $prefixes[ implode( '\\', $parts ) . '\\' ] = true; + array_pop( $parts ); + } + } + + $autoload_chain = spl_autoload_functions(); + if ( ! $autoload_chain ) { + return; + } + + foreach ( $autoload_chain as $autoloader ) { + // No need to check anything after us. + if ( is_array( $autoloader ) && is_string( $autoloader[0] ) && substr( $autoloader[0], 0, strlen( __NAMESPACE__ ) + 1 ) === __NAMESPACE__ . '\\' ) { + break; + } + + // We can check Composer autoloaders easily enough. + if ( is_array( $autoloader ) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader && is_callable( array( $autoloader[0], 'getPrefixesPsr4' ) ) ) { + $composer_autoloader = $autoloader[0]; + foreach ( $composer_autoloader->getClassMap() as $classname => $path ) { + if ( $jetpack_autoloader_loader->find_class_file( $classname ) ) { + $msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the classes we handle (e.g. $classname => $path). This may cause strange and confusing problems."; + wp_trigger_error( '', $msg ); + continue 2; + } + } + foreach ( $composer_autoloader->getPrefixesPsr4() as $prefix => $paths ) { + if ( isset( $prefixes[ $prefix ] ) ) { + $path = array_pop( $paths ); + $msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the namespaces we handle (e.g. $prefix => $path). This may cause strange and confusing problems."; + wp_trigger_error( '', $msg ); + continue 2; + } + } + foreach ( $composer_autoloader->getPrefixes() as $prefix => $paths ) { + if ( isset( $prefixes[ $prefix ] ) ) { + $path = array_pop( $paths ); + $msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the namespaces we handle (e.g. $prefix => $path). This may cause strange and confusing problems."; + wp_trigger_error( '', $msg ); + continue 2; + } + } + } + } + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-manifest-reader.php b/lib/vendor/automattic/jetpack-autoloader/src/class-manifest-reader.php new file mode 100644 index 0000000000..8eb4825fb2 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-manifest-reader.php @@ -0,0 +1,91 @@ +version_selector = $version_selector; + } + + /** + * Reads all of the manifests in the given plugin paths. + * + * @param array $plugin_paths The paths to the plugins we're loading the manifest in. + * @param string $manifest_path The path that we're loading the manifest from in each plugin. + * @param array $path_map The path map to add the contents of the manifests to. + * + * @return array $path_map The path map we've built using the manifests in each plugin. + */ + public function read_manifests( $plugin_paths, $manifest_path, &$path_map ) { + $file_paths = array_map( + function ( $path ) use ( $manifest_path ) { + return trailingslashit( $path ) . $manifest_path; + }, + $plugin_paths + ); + + foreach ( $file_paths as $path ) { + $this->register_manifest( $path, $path_map ); + } + + return $path_map; + } + + /** + * Registers a plugin's manifest file with the path map. + * + * @param string $manifest_path The absolute path to the manifest that we're loading. + * @param array $path_map The path map to add the contents of the manifest to. + */ + protected function register_manifest( $manifest_path, &$path_map ) { + if ( ! is_readable( $manifest_path ) ) { + return; + } + + $manifest = require $manifest_path; + if ( ! is_array( $manifest ) ) { + return; + } + + foreach ( $manifest as $key => $data ) { + $this->register_record( $key, $data, $path_map ); + } + } + + /** + * Registers an entry from the manifest in the path map. + * + * @param string $key The identifier for the entry we're registering. + * @param array $data The data for the entry we're registering. + * @param array $path_map The path map to add the contents of the manifest to. + */ + protected function register_record( $key, $data, &$path_map ) { + if ( isset( $path_map[ $key ]['version'] ) ) { + $selected_version = $path_map[ $key ]['version']; + } else { + $selected_version = null; + } + + if ( $this->version_selector->is_version_update_required( $selected_version, $data['version'] ) ) { + $path_map[ $key ] = array( + 'version' => $data['version'], + 'path' => $data['path'], + ); + } + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-path-processor.php b/lib/vendor/automattic/jetpack-autoloader/src/class-path-processor.php new file mode 100644 index 0000000000..4763274eee --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-path-processor.php @@ -0,0 +1,186 @@ +get_normalized_constants(); + foreach ( $constants as $constant => $constant_path ) { + $len = strlen( $constant_path ); + if ( substr( $path, 0, $len ) !== $constant_path ) { + continue; + } + + return substr_replace( $path, '{{' . $constant . '}}', 0, $len ); + } + + return $path; + } + + /** + * Given a path this will replace any of the path constant tokens with the expanded path. + * + * @param string $tokenized_path The path we want to process. + * + * @return string The expanded path. + */ + public function untokenize_path_constants( $tokenized_path ) { + $tokenized_path = wp_normalize_path( $tokenized_path ); + + $constants = $this->get_normalized_constants(); + foreach ( $constants as $constant => $constant_path ) { + $constant = '{{' . $constant . '}}'; + + $len = strlen( $constant ); + if ( substr( $tokenized_path, 0, $len ) !== $constant ) { + continue; + } + + return $this->get_real_path( substr_replace( $tokenized_path, $constant_path, 0, $len ) ); + } + + return $tokenized_path; + } + + /** + * Given a file and an array of places it might be, this will find the absolute path and return it. + * + * @param string $file The plugin or theme file to resolve. + * @param array $directories_to_check The directories we should check for the file if it isn't an absolute path. + * + * @return string|false Returns the absolute path to the directory, otherwise false. + */ + public function find_directory_with_autoloader( $file, $directories_to_check ) { + $file = wp_normalize_path( $file ); + + if ( ! $this->is_absolute_path( $file ) ) { + $file = $this->find_absolute_plugin_path( $file, $directories_to_check ); + if ( ! isset( $file ) ) { + return false; + } + } + + // We need the real path for consistency with __DIR__ paths. + $file = $this->get_real_path( $file ); + + // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged + $directory = @is_file( $file ) ? dirname( $file ) : $file; + if ( ! @is_file( $directory . '/vendor/composer/jetpack_autoload_classmap.php' ) ) { + return false; + } + // phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged + + return $directory; + } + + /** + * Fetches an array of normalized paths keyed by the constant they came from. + * + * @return string[] The normalized paths keyed by the constant. + */ + private function get_normalized_constants() { + $raw_constants = array( + // Order the constants from most-specific to least-specific. + 'WP_PLUGIN_DIR', + 'WPMU_PLUGIN_DIR', + 'WP_CONTENT_DIR', + 'ABSPATH', + ); + + $constants = array(); + foreach ( $raw_constants as $raw ) { + if ( ! defined( $raw ) ) { + continue; + } + + $path = wp_normalize_path( constant( $raw ) ); + if ( isset( $path ) ) { + $constants[ $raw ] = $path; + } + } + + return $constants; + } + + /** + * Indicates whether or not a path is absolute. + * + * @param string $path The path to check. + * + * @return bool True if the path is absolute, otherwise false. + */ + private function is_absolute_path( $path ) { + if ( empty( $path ) || 0 === strlen( $path ) || '.' === $path[0] ) { + return false; + } + + // Absolute paths on Windows may begin with a drive letter. + if ( preg_match( '/^[a-zA-Z]:[\/\\\\]/', $path ) ) { + return true; + } + + // A path starting with / or \ is absolute; anything else is relative. + return ( '/' === $path[0] || '\\' === $path[0] ); + } + + /** + * Given a file and a list of directories to check, this method will try to figure out + * the absolute path to the file in question. + * + * @param string $normalized_path The normalized path to the plugin or theme file to resolve. + * @param array $directories_to_check The directories we should check for the file if it isn't an absolute path. + * + * @return string|null The absolute path to the plugin file, otherwise null. + */ + private function find_absolute_plugin_path( $normalized_path, $directories_to_check ) { + // We're only able to find the absolute path for plugin/theme PHP files. + if ( ! is_string( $normalized_path ) || '.php' !== substr( $normalized_path, -4 ) ) { + return null; + } + + foreach ( $directories_to_check as $directory ) { + $normalized_check = wp_normalize_path( trailingslashit( $directory ) ) . $normalized_path; + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( @is_file( $normalized_check ) ) { + return $normalized_check; + } + } + + return null; + } + + /** + * Given a path this will figure out the real path that we should be using. + * + * @param string $path The path to resolve. + * + * @return string The resolved path. + */ + private function get_real_path( $path ) { + // We want to resolve symbolic links for consistency with __DIR__ paths. + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $real_path = @realpath( $path ); + if ( false === $real_path ) { + // Let the autoloader deal with paths that don't exist. + $real_path = $path; + } + + // Using realpath will make it platform-specific so we must normalize it after. + if ( $path !== $real_path ) { + $real_path = wp_normalize_path( $real_path ); + } + + return $real_path; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-php-autoloader.php b/lib/vendor/automattic/jetpack-autoloader/src/class-php-autoloader.php new file mode 100644 index 0000000000..f8765a4425 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-php-autoloader.php @@ -0,0 +1,97 @@ +unregister_autoloader(); + + // Set the global so that it can be used to load classes. + global $jetpack_autoloader_loader; + $jetpack_autoloader_loader = $version_loader; + + // Ensure that the autoloader is first to avoid contention with others. + spl_autoload_register( array( self::class, 'load_class' ), true, true ); + } + + /** + * Unregisters the active autoloader so that it will no longer autoload classes. + */ + public function unregister_autoloader() { + // Remove any v2 autoloader that we've already registered. + $autoload_chain = spl_autoload_functions(); + if ( ! $autoload_chain ) { + return; + } + foreach ( $autoload_chain as $autoloader ) { + // We can identify a v2 autoloader using the namespace. + $namespace_check = null; + + // Functions are recorded as strings. + if ( is_string( $autoloader ) ) { + $namespace_check = $autoloader; + } elseif ( is_array( $autoloader ) && is_string( $autoloader[0] ) ) { + // Static method calls have the class as the first array element. + $namespace_check = $autoloader[0]; + } else { + // Since the autoloader has only ever been a function or a static method we don't currently need to check anything else. + continue; + } + + // Check for the namespace without the generated suffix. + if ( 'Automattic\\Jetpack\\Autoloader\\jp' === substr( $namespace_check, 0, 32 ) ) { + spl_autoload_unregister( $autoloader ); + } + } + + // Clear the global now that the autoloader has been unregistered. + global $jetpack_autoloader_loader; + $jetpack_autoloader_loader = null; + } + + /** + * Loads a class file if one could be found. + * + * Note: This function is static so that the autoloader can be easily unregistered. If + * it was a class method we would have to unwrap the object to check the namespace. + * + * @param string $class_name The name of the class to autoload. + * + * @return bool Indicates whether or not a class file was loaded. + */ + public static function load_class( $class_name ) { + global $jetpack_autoloader_loader; + if ( ! isset( $jetpack_autoloader_loader ) ) { + return false; + } + + $file = $jetpack_autoloader_loader->find_class_file( $class_name ); + if ( ! isset( $file ) ) { + return false; + } + + // A common source of strange and confusing problems is when a vendor + // file is autoloaded before all plugins have had a chance to register + // with the autoloader. Detect that, if a development constant is set. + if ( defined( 'JETPACK_AUTOLOAD_DEBUG_EARLY_LOADS' ) && JETPACK_AUTOLOAD_DEBUG_EARLY_LOADS && + ( strpos( $file, '/vendor/' ) !== false || strpos( $file, '/jetpack_vendor/' ) !== false ) && + is_callable( 'did_action' ) && ! did_action( 'plugins_loaded' ) + ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary -- This is a debug log message. + $msg = "Jetpack Autoloader: Autoloading `$class_name` before the plugins_loaded hook may cause strange and confusing problems. " . wp_debug_backtrace_summary( '', 1 ); + wp_trigger_error( '', $msg ); + } + + require $file; + return true; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-plugin-locator.php b/lib/vendor/automattic/jetpack-autoloader/src/class-plugin-locator.php new file mode 100644 index 0000000000..8dafeff50d --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-plugin-locator.php @@ -0,0 +1,145 @@ +path_processor = $path_processor; + } + + /** + * Finds the path to the current plugin. + * + * @return string $path The path to the current plugin. + * + * @throws \RuntimeException If the current plugin does not have an autoloader. + */ + public function find_current_plugin() { + // Escape from `vendor/__DIR__` to root plugin directory. + $plugin_directory = dirname( __DIR__, 2 ); + + // Use the path processor to ensure that this is an autoloader we're referencing. + $path = $this->path_processor->find_directory_with_autoloader( $plugin_directory, array() ); + if ( false === $path ) { + throw new \RuntimeException( 'Failed to locate plugin ' . $plugin_directory ); + } + + return $path; + } + + /** + * Checks a given option for plugin paths. + * + * @param string $option_name The option that we want to check for plugin information. + * @param bool $site_option Indicates whether or not we want to check the site option. + * + * @return array $plugin_paths The list of absolute paths we've found. + */ + public function find_using_option( $option_name, $site_option = false ) { + $raw = $site_option ? get_site_option( $option_name ) : get_option( $option_name ); + if ( false === $raw ) { + return array(); + } + + return $this->convert_plugins_to_paths( $raw ); + } + + /** + * Checks for plugins in the `action` request parameter. + * + * @param string[] $allowed_actions The actions that we're allowed to return plugins for. + * + * @return array $plugin_paths The list of absolute paths we've found. + */ + public function find_using_request_action( $allowed_actions ) { + /** + * Note: we're not actually checking the nonce here because it's too early + * in the execution. The pluggable functions are not yet loaded to give + * plugins a chance to plug their versions. Therefore we're doing the bare + * minimum: checking whether the nonce exists and it's in the right place. + * The request will fail later if the nonce doesn't pass the check. + */ + if ( empty( $_REQUEST['_wpnonce'] ) ) { + return array(); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated just below. + $action = isset( $_REQUEST['action'] ) ? wp_unslash( $_REQUEST['action'] ) : false; + if ( ! in_array( $action, $allowed_actions, true ) ) { + return array(); + } + + $plugin_slugs = array(); + switch ( $action ) { + case 'activate': + case 'deactivate': + if ( empty( $_REQUEST['plugin'] ) ) { + break; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by convert_plugins_to_paths. + $plugin_slugs[] = wp_unslash( $_REQUEST['plugin'] ); + break; + + case 'activate-selected': + case 'deactivate-selected': + if ( empty( $_REQUEST['checked'] ) ) { + break; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by convert_plugins_to_paths. + $plugin_slugs = wp_unslash( $_REQUEST['checked'] ); + break; + } + + return $this->convert_plugins_to_paths( $plugin_slugs ); + } + + /** + * Given an array of plugin slugs or paths, this will convert them to absolute paths and filter + * out the plugins that are not directory plugins. Note that array keys will also be included + * if they are plugin paths! + * + * @param string[] $plugins Plugin paths or slugs to filter. + * + * @return string[] + */ + private function convert_plugins_to_paths( $plugins ) { + if ( ! is_array( $plugins ) || empty( $plugins ) ) { + return array(); + } + + // We're going to look for plugins in the standard directories. + $path_constants = array( WP_PLUGIN_DIR, WPMU_PLUGIN_DIR ); + + $plugin_paths = array(); + foreach ( $plugins as $key => $value ) { + $path = $this->path_processor->find_directory_with_autoloader( $key, $path_constants ); + if ( $path ) { + $plugin_paths[] = $path; + } + + $path = $this->path_processor->find_directory_with_autoloader( $value, $path_constants ); + if ( $path ) { + $plugin_paths[] = $path; + } + } + + return $plugin_paths; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-plugins-handler.php b/lib/vendor/automattic/jetpack-autoloader/src/class-plugins-handler.php new file mode 100644 index 0000000000..dd00ac1219 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-plugins-handler.php @@ -0,0 +1,156 @@ +plugin_locator = $plugin_locator; + $this->path_processor = $path_processor; + } + + /** + * Gets all of the active plugins we can find. + * + * @param bool $include_deactivating When true, plugins deactivating this request will be considered active. + * @param bool $record_unknown When true, the current plugin will be marked as active and recorded when unknown. + * + * @return string[] + */ + public function get_active_plugins( $include_deactivating, $record_unknown ) { + global $jetpack_autoloader_activating_plugins_paths; + + // We're going to build a unique list of plugins from a few different sources + // to find all of our "active" plugins. While we need to return an integer + // array, we're going to use an associative array internally to reduce + // the amount of time that we're going to spend checking uniqueness + // and merging different arrays together to form the output. + $active_plugins = array(); + + // Make sure that plugins which have activated this request are considered as "active" even though + // they probably won't be present in any option. + if ( is_array( $jetpack_autoloader_activating_plugins_paths ) ) { + foreach ( $jetpack_autoloader_activating_plugins_paths as $path ) { + $active_plugins[ $path ] = $path; + } + } + + // This option contains all of the plugins that have been activated. + $plugins = $this->plugin_locator->find_using_option( 'active_plugins' ); + foreach ( $plugins as $path ) { + $active_plugins[ $path ] = $path; + } + + // This option contains all of the multisite plugins that have been activated. + if ( is_multisite() ) { + $plugins = $this->plugin_locator->find_using_option( 'active_sitewide_plugins', true ); + foreach ( $plugins as $path ) { + $active_plugins[ $path ] = $path; + } + } + + // These actions contain plugins that are being activated/deactivated during this request. + $plugins = $this->plugin_locator->find_using_request_action( array( 'activate', 'activate-selected', 'deactivate', 'deactivate-selected' ) ); + foreach ( $plugins as $path ) { + $active_plugins[ $path ] = $path; + } + + // When the current plugin isn't considered "active" there's a problem. + // Since we're here, the plugin is active and currently being loaded. + // We can support this case (mu-plugins and non-standard activation) + // by adding the current plugin to the active list and marking it + // as an unknown (activating) plugin. This also has the benefit + // of causing a reset because the active plugins list has + // been changed since it was saved in the global. + $current_plugin = $this->plugin_locator->find_current_plugin(); + if ( $record_unknown && ! in_array( $current_plugin, $active_plugins, true ) ) { + $active_plugins[ $current_plugin ] = $current_plugin; + $jetpack_autoloader_activating_plugins_paths[] = $current_plugin; + } + + // When deactivating plugins aren't desired we should entirely remove them from the active list. + if ( ! $include_deactivating ) { + // These actions contain plugins that are being deactivated during this request. + $plugins = $this->plugin_locator->find_using_request_action( array( 'deactivate', 'deactivate-selected' ) ); + foreach ( $plugins as $path ) { + unset( $active_plugins[ $path ] ); + } + } + + // Transform the array so that we don't have to worry about the keys interacting with other array types later. + return array_values( $active_plugins ); + } + + /** + * Gets all of the cached plugins if there are any. + * + * @return string[] + */ + public function get_cached_plugins() { + $cached = get_transient( self::TRANSIENT_KEY ); + if ( ! is_array( $cached ) || empty( $cached ) ) { + return array(); + } + + // We need to expand the tokens to an absolute path for this webserver. + return array_map( array( $this->path_processor, 'untokenize_path_constants' ), $cached ); + } + + /** + * Saves the plugin list to the cache. + * + * @param array $plugins The plugin list to save to the cache. + */ + public function cache_plugins( $plugins ) { + // We store the paths in a tokenized form so that that webservers with different absolute paths don't break. + $plugins = array_map( array( $this->path_processor, 'tokenize_path_constants' ), $plugins ); + + set_transient( self::TRANSIENT_KEY, $plugins ); + } + + /** + * Checks to see whether or not the plugin list given has changed when compared to the + * shared `$jetpack_autoloader_cached_plugin_paths` global. This allows us to deal + * with cases where the active list may change due to filtering.. + * + * @param string[] $plugins The plugins list to check against the global cache. + * + * @return bool True if the plugins have changed, otherwise false. + */ + public function have_plugins_changed( $plugins ) { + global $jetpack_autoloader_cached_plugin_paths; + + if ( $jetpack_autoloader_cached_plugin_paths !== $plugins ) { + $jetpack_autoloader_cached_plugin_paths = $plugins; + return true; + } + + return false; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-shutdown-handler.php b/lib/vendor/automattic/jetpack-autoloader/src/class-shutdown-handler.php new file mode 100644 index 0000000000..198b19c6f5 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-shutdown-handler.php @@ -0,0 +1,84 @@ +plugins_handler = $plugins_handler; + $this->cached_plugins = $cached_plugins; + $this->was_included_by_autoloader = $was_included_by_autoloader; + } + + /** + * Handles the shutdown of the autoloader. + */ + public function __invoke() { + // Don't save a broken cache if an error happens during some plugin's initialization. + if ( ! did_action( 'plugins_loaded' ) ) { + // Ensure that the cache is emptied to prevent consecutive failures if the cache is to blame. + if ( ! empty( $this->cached_plugins ) ) { + $this->plugins_handler->cache_plugins( array() ); + } + + return; + } + + // Load the active plugins fresh since the list we pulled earlier might not contain + // plugins that were activated but did not reset the autoloader. This happens + // when a plugin is in the cache but not "active" when the autoloader loads. + // We also want to make sure that plugins which are deactivating are not + // considered "active" so that they will be removed from the cache now. + try { + $active_plugins = $this->plugins_handler->get_active_plugins( false, ! $this->was_included_by_autoloader ); + } catch ( \Exception $ex ) { + // When the package is deleted before shutdown it will throw an exception. + // In the event this happens we should erase the cache. + if ( ! empty( $this->cached_plugins ) ) { + $this->plugins_handler->cache_plugins( array() ); + } + return; + } + + // The paths should be sorted for easy comparisons with those loaded from the cache. + // Note we don't need to sort the cached entries because they're already sorted. + sort( $active_plugins ); + + // We don't want to waste time saving a cache that hasn't changed. + if ( $this->cached_plugins === $active_plugins ) { + return; + } + + $this->plugins_handler->cache_plugins( $active_plugins ); + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-version-loader.php b/lib/vendor/automattic/jetpack-autoloader/src/class-version-loader.php new file mode 100644 index 0000000000..cc7dcd6d4d --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-version-loader.php @@ -0,0 +1,176 @@ +version_selector = $version_selector; + $this->classmap = $classmap; + $this->psr4_map = $psr4_map; + $this->filemap = $filemap; + } + + /** + * Fetch the classmap. + * + * @since 3.1.0 + * @return array + */ + public function get_class_map() { + return $this->classmap; + } + + /** + * Fetch the psr-4 mappings. + * + * @since 3.1.0 + * @return array + */ + public function get_psr4_map() { + return $this->psr4_map; + } + + /** + * Finds the file path for the given class. + * + * @param string $class_name The class to find. + * + * @return string|null $file_path The path to the file if found, null if no class was found. + */ + public function find_class_file( $class_name ) { + $data = $this->select_newest_file( + $this->classmap[ $class_name ] ?? null, + $this->find_psr4_file( $class_name ) + ); + if ( ! isset( $data ) ) { + return null; + } + + return $data['path']; + } + + /** + * Load all of the files in the filemap. + */ + public function load_filemap() { + if ( empty( $this->filemap ) ) { + return; + } + + foreach ( $this->filemap as $file_identifier => $file_data ) { + if ( empty( $GLOBALS['__composer_autoload_files'][ $file_identifier ] ) ) { + require_once $file_data['path']; + + $GLOBALS['__composer_autoload_files'][ $file_identifier ] = true; + } + } + } + + /** + * Compares different class sources and returns the newest. + * + * @param array|null $classmap_data The classmap class data. + * @param array|null $psr4_data The PSR-4 class data. + * + * @return array|null $data + */ + private function select_newest_file( $classmap_data, $psr4_data ) { + if ( ! isset( $classmap_data ) ) { + return $psr4_data; + } elseif ( ! isset( $psr4_data ) ) { + return $classmap_data; + } + + if ( $this->version_selector->is_version_update_required( $classmap_data['version'], $psr4_data['version'] ) ) { + return $psr4_data; + } + + return $classmap_data; + } + + /** + * Finds the file for a given class in a PSR-4 namespace. + * + * @param string $class_name The class to find. + * + * @return array|null $data The version and path path to the file if found, null otherwise. + */ + private function find_psr4_file( $class_name ) { + if ( empty( $this->psr4_map ) ) { + return null; + } + + // Don't bother with classes that have no namespace. + $class_index = strrpos( $class_name, '\\' ); + if ( ! $class_index ) { + return null; + } + $class_for_path = str_replace( '\\', '/', $class_name ); + + // Search for the namespace by iteratively cutting off the last segment until + // we find a match. This allows us to check the most-specific namespaces + // first as well as minimize the amount of time spent looking. + for ( + $class_namespace = substr( $class_name, 0, $class_index ); + ! empty( $class_namespace ); + $class_namespace = substr( $class_namespace, 0, strrpos( $class_namespace, '\\' ) ) + ) { + $namespace = $class_namespace . '\\'; + if ( ! isset( $this->psr4_map[ $namespace ] ) ) { + continue; + } + $data = $this->psr4_map[ $namespace ]; + + foreach ( $data['path'] as $path ) { + $path .= '/' . substr( $class_for_path, strlen( $namespace ) ) . '.php'; + if ( file_exists( $path ) ) { + return array( + 'version' => $data['version'], + 'path' => $path, + ); + } + } + } + + return null; + } +} diff --git a/lib/vendor/automattic/jetpack-autoloader/src/class-version-selector.php b/lib/vendor/automattic/jetpack-autoloader/src/class-version-selector.php new file mode 100644 index 0000000000..5b201ff920 --- /dev/null +++ b/lib/vendor/automattic/jetpack-autoloader/src/class-version-selector.php @@ -0,0 +1,61 @@ +is_dev_version( $selected_version ) ) { + return false; + } + + if ( $this->is_dev_version( $compare_version ) ) { + if ( $use_dev_versions ) { + return true; + } else { + return false; + } + } + + if ( version_compare( $selected_version, $compare_version, '<' ) ) { + return true; + } + + return false; + } + + /** + * Checks whether the given package version is a development version. + * + * @param String $version The package version. + * + * @return bool True if the version is a dev version, else false. + */ + public function is_dev_version( $version ) { + if ( 'dev-' === substr( $version, 0, 4 ) || '9999999-dev' === $version ) { + return true; + } + + return false; + } +} diff --git a/lib/vendor/composer/ClassLoader.php b/lib/vendor/composer/ClassLoader.php new file mode 100644 index 0000000000..7824d8f7ea --- /dev/null +++ b/lib/vendor/composer/ClassLoader.php @@ -0,0 +1,579 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/lib/vendor/composer/InstalledVersions.php b/lib/vendor/composer/InstalledVersions.php new file mode 100644 index 0000000000..51e734a774 --- /dev/null +++ b/lib/vendor/composer/InstalledVersions.php @@ -0,0 +1,359 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + + if (self::$canGetVendors) { + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + $installed[] = self::$installedByVendor[$vendorDir] = $required; + if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { + self::$installed = $installed[count($installed) - 1]; + } + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array()) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/lib/vendor/composer/LICENSE b/lib/vendor/composer/LICENSE new file mode 100644 index 0000000000..f27399a042 --- /dev/null +++ b/lib/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/lib/vendor/composer/autoload_classmap.php b/lib/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000000..37f9cb7483 --- /dev/null +++ b/lib/vendor/composer/autoload_classmap.php @@ -0,0 +1,11 @@ + $vendorDir . '/automattic/jetpack-autoloader/src/AutoloadGenerator.php', + 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', +); diff --git a/lib/vendor/composer/autoload_namespaces.php b/lib/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000000..15a2ff3ad6 --- /dev/null +++ b/lib/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($vendorDir . '/wordpress/php-mcp-schema/src'), + 'WP\\MCP\\' => array($vendorDir . '/wordpress/mcp-adapter/includes'), + 'Automattic\\Jetpack\\Autoloader\\' => array($vendorDir . '/automattic/jetpack-autoloader/src'), +); diff --git a/lib/vendor/composer/autoload_real.php b/lib/vendor/composer/autoload_real.php new file mode 100644 index 0000000000..883299d144 --- /dev/null +++ b/lib/vendor/composer/autoload_real.php @@ -0,0 +1,38 @@ +register(true); + + return $loader; + } +} diff --git a/lib/vendor/composer/autoload_static.php b/lib/vendor/composer/autoload_static.php new file mode 100644 index 0000000000..74945235a3 --- /dev/null +++ b/lib/vendor/composer/autoload_static.php @@ -0,0 +1,50 @@ + + array ( + 'WP\\McpSchema\\' => 13, + 'WP\\MCP\\' => 7, + ), + 'A' => + array ( + 'Automattic\\Jetpack\\Autoloader\\' => 30, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'WP\\McpSchema\\' => + array ( + 0 => __DIR__ . '/..' . '/wordpress/php-mcp-schema/src', + ), + 'WP\\MCP\\' => + array ( + 0 => __DIR__ . '/..' . '/wordpress/mcp-adapter/includes', + ), + 'Automattic\\Jetpack\\Autoloader\\' => + array ( + 0 => __DIR__ . '/..' . '/automattic/jetpack-autoloader/src', + ), + ); + + public static $classMap = array ( + 'Automattic\\Jetpack\\Autoloader\\AutoloadGenerator' => __DIR__ . '/..' . '/automattic/jetpack-autoloader/src/AutoloadGenerator.php', + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit0e68d2c8a922435e3fc7e74f9130bde4::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit0e68d2c8a922435e3fc7e74f9130bde4::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit0e68d2c8a922435e3fc7e74f9130bde4::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/lib/vendor/composer/installed.json b/lib/vendor/composer/installed.json new file mode 100644 index 0000000000..46261f481c --- /dev/null +++ b/lib/vendor/composer/installed.json @@ -0,0 +1,198 @@ +{ + "packages": [ + { + "name": "automattic/jetpack-autoloader", + "version": "v5.0.23", + "version_normalized": "5.0.23.0", + "source": { + "type": "git", + "url": "https://github.com/Automattic/jetpack-autoloader.git", + "reference": "d11b2d621035dcb920abce8ae09bebd5da5f9ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/d11b2d621035dcb920abce8ae09bebd5da5f9ff8", + "reference": "d11b2d621035dcb920abce8ae09bebd5da5f9ff8", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=7.2" + }, + "require-dev": { + "automattic/phpunit-select-config": "^1.0.9", + "composer/composer": "^2.2", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "time": "2026-08-11T18:32:06+00:00", + "type": "composer-plugin", + "extra": { + "class": "Automattic\\Jetpack\\Autoloader\\CustomAutoloaderPlugin", + "autotagger": true, + "mirror-repo": "Automattic/jetpack-autoloader", + "branch-alias": { + "dev-trunk": "5.0.x-dev" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-autoloader/compare/v${old}...v${new}" + }, + "version-constants": { + "::VERSION": "src/AutoloadGenerator.php" + } + }, + "installation-source": "source", + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Autoloader\\": "src" + }, + "classmap": [ + "src/AutoloadGenerator.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "Creates a custom autoloader for a plugin or theme.", + "keywords": [ + "autoload", + "autoloader", + "composer", + "jetpack", + "plugin", + "wordpress" + ], + "support": { + "source": "https://github.com/Automattic/jetpack-autoloader/tree/v5.0.23" + }, + "install-path": "../automattic/jetpack-autoloader" + }, + { + "name": "wordpress/mcp-adapter", + "version": "v0.6.1", + "version_normalized": "0.6.1.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/mcp-adapter", + "reference": "23cb53e0b82f39238eec1c38cb055e28aa30fa7c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/23cb53e0b82f39238eec1c38cb055e28aa30fa7c", + "reference": "23cb53e0b82f39238eec1c38cb055e28aa30fa7c", + "shasum": "" + }, + "require": { + "automattic/jetpack-autoloader": "^5.0", + "ext-json": "*", + "php": "^7.4 || ^8.0", + "wordpress/php-mcp-schema": "^0.1.0" + }, + "require-dev": { + "automattic/vipwpcs": "^3.1", + "php-stubs/wordpress-stubs": "^6.9", + "php-stubs/wp-cli-stubs": "^2.12", + "phpcompatibility/phpcompatibility-wp": "^3.0.0-alpha", + "phpstan/extension-installer": "^1.3", + "phpstan/php-8-stubs": "^0.4.36", + "phpstan/phpstan": "^2.2.6", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.0", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-phpunit/wp-phpunit": "^7.0", + "yoast/phpunit-polyfills": "^4.0" + }, + "time": "2026-08-13T05:16:13+00:00", + "type": "wordpress-plugin", + "installation-source": "source", + "autoload": { + "psr-4": { + "WP\\MCP\\": "includes/" + }, + "exclude-from-classmap": [ + "tests/phpunit/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Adapter for Abilities API, letting WordPress abilities to be used as MCP tools, resources or prompts", + "homepage": "https://github.com/wordpress/mcp-adapter", + "keywords": [ + "abilities-api", + "adapter", + "ai", + "api", + "integration", + "mcp", + "model-context-protocol", + "wordpress" + ], + "support": { + "issues": "https://github.com/wordpress/mcp-adapter/issues", + "source": "https://github.com/wordpress/mcp-adapter" + }, + "install-path": "../wordpress/mcp-adapter" + }, + { + "name": "wordpress/php-mcp-schema", + "version": "v0.1.3", + "version_normalized": "0.1.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/php-mcp-schema", + "reference": "b2fcf97aa023ce46e9f03493c194a72d5a46bea2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/php-mcp-schema/zipball/b2fcf97aa023ce46e9f03493c194a72d5a46bea2", + "reference": "b2fcf97aa023ce46e9f03493c194a72d5a46bea2", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpstan/phpstan": "^1.10" + }, + "time": "2026-08-10T09:12:14+00:00", + "type": "library", + "installation-source": "source", + "autoload": { + "psr-4": { + "WP\\McpSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress", + "homepage": "https://wordpress.org" + } + ], + "description": "PHP DTOs for the Model Context Protocol (MCP) specification", + "homepage": "https://github.com/WordPress/php-mcp-schema", + "keywords": [ + "dto", + "mcp", + "model-context-protocol", + "php", + "schema" + ], + "install-path": "../wordpress/php-mcp-schema" + } + ], + "dev": true, + "dev-package-names": [] +} diff --git a/lib/vendor/composer/installed.php b/lib/vendor/composer/installed.php new file mode 100644 index 0000000000..c0a4d84cdb --- /dev/null +++ b/lib/vendor/composer/installed.php @@ -0,0 +1,50 @@ + array( + 'name' => 'strategy11/formidable-lib', + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev' => true, + ), + 'versions' => array( + 'automattic/jetpack-autoloader' => array( + 'pretty_version' => 'v5.0.23', + 'version' => '5.0.23.0', + 'reference' => 'd11b2d621035dcb920abce8ae09bebd5da5f9ff8', + 'type' => 'composer-plugin', + 'install_path' => __DIR__ . '/../automattic/jetpack-autoloader', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'strategy11/formidable-lib' => array( + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'wordpress/mcp-adapter' => array( + 'pretty_version' => 'v0.6.1', + 'version' => '0.6.1.0', + 'reference' => '23cb53e0b82f39238eec1c38cb055e28aa30fa7c', + 'type' => 'wordpress-plugin', + 'install_path' => __DIR__ . '/../wordpress/mcp-adapter', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'wordpress/php-mcp-schema' => array( + 'pretty_version' => 'v0.1.3', + 'version' => '0.1.3.0', + 'reference' => 'b2fcf97aa023ce46e9f03493c194a72d5a46bea2', + 'type' => 'library', + 'install_path' => __DIR__ . '/../wordpress/php-mcp-schema', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/lib/vendor/composer/platform_check.php b/lib/vendor/composer/platform_check.php new file mode 100644 index 0000000000..580fa96095 --- /dev/null +++ b/lib/vendor/composer/platform_check.php @@ -0,0 +1,26 @@ += 70400)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 7.4.0". You are running ' . PHP_VERSION . '.'; +} + +if ($issues) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; + } + } + trigger_error( + 'Composer detected issues in your platform: ' . implode(' ', $issues), + E_USER_ERROR + ); +} diff --git a/lib/vendor/wordpress/mcp-adapter/.gitignore b/lib/vendor/wordpress/mcp-adapter/.gitignore new file mode 100644 index 0000000000..07f2f12083 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/.gitignore @@ -0,0 +1,49 @@ +### WordPress ### + +# macOS/IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace + +# Log files +*.log +/tests/logs + +# Dependencies +/vendor/ +/node_modules/ + +# Build output +build/ + +# Generated language files +/languages/* +!/languages/.gitkeep + +# Environment +.env + +# AI files +.gemini/ +.claude/ +AGENTS.md +GEMINI.md +CLAUDE.md + +# Test output +/tests/_output/* +!/tests/_output/.gitkeep + +# Configs +/phpcs.xml +/phpunit.xml +/phpstan.neon + +# WP ENV +/.wp-env.override.json + +# Build +mcp-adapter.zip diff --git a/lib/vendor/wordpress/mcp-adapter/LICENSE.md b/lib/vendor/wordpress/mcp-adapter/LICENSE.md new file mode 100644 index 0000000000..d159169d10 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/LICENSE.md @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Abilities/DiscoverAbilitiesAbility.php b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/DiscoverAbilitiesAbility.php new file mode 100644 index 0000000000..7104ded5cf --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/DiscoverAbilitiesAbility.php @@ -0,0 +1,151 @@ + 'Discover Abilities', + 'description' => 'Discover all available WordPress abilities in the system. Returns a list of all registered abilities with their basic information.', + 'category' => 'mcp-adapter', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'abilities' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'label' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + ), + 'required' => array( 'name', 'label', 'description' ), + ), + ), + ), + 'required' => array( 'abilities' ), + ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'execute_callback' => array( self::class, 'execute' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ) + ); + } + + /** + * Execute the discover abilities functionality. + * + * Note: Permission checks are handled by the WP_Ability::execute() framework method + * before this callback is invoked. + * + * @see \WP_Ability::execute() + * + * @param array $input Input parameters (unused for this ability). + * + * @return array Array containing public MCP abilities. + */ + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by the ability callback. + public static function execute( $input = array() ): array { + // Get all abilities and filter for publicly exposed ones + $abilities = wp_get_abilities(); + + $ability_list = array(); + foreach ( $abilities as $ability ) { + $ability_name = $ability->get_name(); + + // Check if ability is publicly exposed via MCP + if ( ! self::is_ability_mcp_public( $ability ) ) { + continue; + } + + // Only discover abilities with type='tool' (default type) + if ( self::get_ability_mcp_type( $ability ) !== 'tool' ) { + continue; + } + + $ability_list[] = array( + 'name' => $ability_name, + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + ); + } + + return array( + 'abilities' => $ability_list, + ); + } + + /** + * Check permissions for discovering abilities. + * + * Validates user capabilities and caller identity. + * + * @param array $input Input parameters (unused for this ability). + * + * @return bool|\WP_Error True if the user has permission to discover abilities. + */ + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by the ability callback. + public static function check_permission( $input = array() ) { + // Verify caller identity - ensure user is authenticated + if ( ! is_user_logged_in() ) { + return new WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); + } + + /** + * Filters the capability required to discover available abilities. + * + * This capability is checked before listing all registered WordPress abilities + * through the mcp-adapter-discover-abilities tool. + * + * @since 0.3.0 + * + * @param string $capability The required capability. Default 'read'. + */ + $required_capability = apply_filters( 'mcp_adapter_discover_abilities_capability', 'read' ); + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter + if ( ! current_user_can( $required_capability ) ) { + return new WP_Error( + 'insufficient_capability', + sprintf( 'User lacks required capability: %s', $required_capability ) + ); + } + + return true; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Abilities/ExecuteAbilityAbility.php b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/ExecuteAbilityAbility.php new file mode 100644 index 0000000000..76fd2591b9 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/ExecuteAbilityAbility.php @@ -0,0 +1,237 @@ + 'Execute Ability', + 'description' => 'Execute a WordPress ability with the provided parameters. This is the primary execution layer that can run any registered ability.', + 'category' => 'mcp-adapter', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'ability_name' => array( + 'type' => 'string', + 'description' => 'The full name of the ability to execute', + ), + 'parameters' => array( + 'type' => 'object', + 'description' => 'Parameters to pass to the ability', + ), + ), + 'required' => array( 'ability_name', 'parameters' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'data' => array( + 'type' => array( + 'object', + 'array', + 'string', + 'number', + 'integer', + 'boolean', + 'null', + ), + 'description' => 'The result data from the ability execution', + ), + 'error' => array( + 'type' => 'string', + 'description' => 'Error message if execution failed', + ), + ), + 'required' => array( 'success' ), + ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'execute_callback' => array( self::class, 'execute' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ), + ), + ) + ); + } + + /** + * Execute the ability execution functionality. + * + * Note: Permission checks are handled by the WP_Ability::execute() framework method + * before this callback is invoked. This ensures all ability executions are properly + * authorized by the framework. + * + * @see \WP_Ability::execute() + * + * @param array $input Input parameters containing ability_name and parameters. + * + * @return array Array containing execution results. + */ + public static function execute( $input = array() ): array { + $ability_name = $input['ability_name'] ?? ''; + // Note: Use null coalescing instead of empty() to preserve empty arrays/objects ({} → []) + $parameters = $input['parameters'] ?? null; + + if ( empty( $ability_name ) ) { + return array( + 'success' => false, + 'error' => 'Ability name is required', + ); + } + + $ability = wp_get_ability( $ability_name ); + + if ( ! $ability ) { + return array( + 'success' => false, + 'error' => "Ability '{$ability_name}' not found", + ); + } + + // Normalize parameters for ability's schema requirements + // Empty {} from MCP is treated as null for abilities without input schema + $parameters = AbilityArgumentNormalizer::normalize( $ability, $parameters ); + + try { + // Execute the ability + $result = $ability->execute( $parameters ); + + // Check if the result is a WP_Error + if ( is_wp_error( $result ) ) { + return array( + 'success' => false, + 'error' => $result->get_error_message(), + ); + } + + return array( + 'success' => true, + 'data' => $result, + ); + } catch ( \Throwable $e ) { + return array( + 'success' => false, + 'error' => $e->getMessage(), + ); + } + } + + /** + * Check permissions for executing abilities. + * + * Validates user capabilities, caller identity, and MCP exposure restrictions. + * + * @param array $input Input parameters containing ability_name and parameters. + * + * @return bool|\WP_Error True if the user has permission to execute the specified ability. + */ + public static function check_permission( $input = array() ) { + $ability_name = $input['ability_name'] ?? ''; + + if ( empty( $ability_name ) ) { + return new WP_Error( 'missing_ability_name', 'Ability name is required' ); + } + + // Validate user authentication and capabilities + $user_check = self::validate_user_access(); + if ( is_wp_error( $user_check ) ) { + return $user_check; + } + + // Check MCP exposure restrictions + $exposure_check = self::check_ability_mcp_exposure( $ability_name ); + if ( is_wp_error( $exposure_check ) ) { + return $exposure_check; + } + + // Get the target ability + $ability = wp_get_ability( $ability_name ); + if ( ! $ability ) { + return new WP_Error( 'ability_not_found', "Ability '{$ability_name}' not found" ); + } + + // Normalize parameters for ability's schema requirements + // Empty {} from MCP is treated as null for abilities without input schema + $parameters = $input['parameters'] ?? null; + $parameters = AbilityArgumentNormalizer::normalize( $ability, $parameters ); + $permission_result = $ability->check_permissions( $parameters ); + + // Return WP_Error as-is, or convert other values to boolean + if ( is_wp_error( $permission_result ) ) { + return $permission_result; + } + + return (bool) $permission_result; + } + + /** + * Validate user authentication and basic capabilities for execute ability. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + private static function validate_user_access() { + // Verify caller identity - ensure the user is authenticated + if ( ! is_user_logged_in() ) { + return new WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); + } + + /** + * Filters the capability required to execute abilities. + * + * This is intentionally set to 'read' as the minimum baseline capability. + * Each ability defines its own permission_callback that enforces the actual + * capability requirements for that specific operation. This filter serves + * only as a gate to prevent completely unauthenticated or capability-less + * users from reaching the ability execution layer. + * + * @since 0.3.0 + * + * @param string $capability The required capability. Default 'read'. + */ + $required_capability = apply_filters( 'mcp_adapter_execute_ability_capability', 'read' ); + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter + if ( ! current_user_can( $required_capability ) ) { + return new WP_Error( + 'insufficient_capability', + sprintf( 'User lacks required capability: %s', $required_capability ) + ); + } + + return true; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Abilities/GetAbilityInfoAbility.php b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/GetAbilityInfoAbility.php new file mode 100644 index 0000000000..b86dead3b5 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/GetAbilityInfoAbility.php @@ -0,0 +1,191 @@ + 'Get Ability Info', + 'description' => 'Get detailed information about a specific WordPress ability including its input/output schema, description, and usage examples.', + 'category' => 'mcp-adapter', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'ability_name' => array( + 'type' => 'string', + 'description' => 'The full name of the ability to get information about', + ), + ), + 'required' => array( 'ability_name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'label' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + 'input_schema' => array( + 'type' => 'object', + 'description' => 'JSON Schema for the ability input parameters', + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => 'JSON Schema for the ability output structure', + ), + 'meta' => array( + 'type' => 'object', + 'description' => 'Additional metadata about the ability', + ), + ), + 'required' => array( 'name', 'label', 'description', 'input_schema' ), + ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'execute_callback' => array( self::class, 'execute' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ) + ); + } + + /** + * Execute the get ability info functionality. + * + * Note: Permission checks are handled by the WP_Ability::execute() framework method + * before this callback is invoked (see WP_Ability::execute() line 605). + * + * @param array $input Input parameters containing ability_name. + * + * @return array Array containing detailed ability information. + */ + public static function execute( $input = array() ): array { + $ability_name = $input['ability_name'] ?? ''; + + if ( empty( $ability_name ) ) { + return array( + 'error' => 'Ability name is required', + ); + } + + $ability = wp_get_ability( $ability_name ); + + if ( ! $ability ) { + return array( + 'error' => "Ability '{$ability_name}' not found", + ); + } + + $ability_info = array( + 'name' => $ability->get_name(), + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + 'input_schema' => $ability->get_input_schema(), + ); + + // Add output schema if available + $output_schema = $ability->get_output_schema(); + if ( ! empty( $output_schema ) ) { + $ability_info['output_schema'] = $output_schema; + } + + // Add meta information if available + $meta = $ability->get_meta(); + if ( ! empty( $meta ) ) { + $ability_info['meta'] = $meta; + } + + return $ability_info; + } + + /** + * Check permissions for getting ability info. + * + * Validates user capabilities, caller identity, and MCP exposure restrictions. + * + * @param array $input Input parameters containing ability_name. + * + * @return bool|\WP_Error True if the user has permission to get ability info. + */ + public static function check_permission( $input = array() ) { + $ability_name = $input['ability_name'] ?? ''; + + if ( empty( $ability_name ) ) { + return new WP_Error( 'missing_ability_name', 'Ability name is required' ); + } + + // Validate user authentication and capabilities + $user_check = self::validate_user_access(); + if ( is_wp_error( $user_check ) ) { + return $user_check; + } + + // Check MCP exposure restrictions + return self::check_ability_mcp_exposure( $ability_name ); + } + + /** + * Validate user authentication and basic capabilities for get ability info. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + private static function validate_user_access() { + // Verify caller identity - ensure user is authenticated + if ( ! is_user_logged_in() ) { + return new WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); + } + + /** + * Filters the capability required to get ability information. + * + * This capability is checked before returning detailed information about + * a specific WordPress ability through the mcp-adapter-get-ability-info tool. + * + * @since 0.3.0 + * + * @param string $capability The required capability. Default 'read'. + */ + $required_capability = apply_filters( 'mcp_adapter_get_ability_info_capability', 'read' ); + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter + if ( ! current_user_can( $required_capability ) ) { + return new WP_Error( + 'insufficient_capability', + sprintf( 'User lacks required capability: %s', $required_capability ) + ); + } + + return true; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityExposure.php b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityExposure.php new file mode 100644 index 0000000000..06aaef0737 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityExposure.php @@ -0,0 +1,69 @@ +get_meta() ); + } + + /** + * Determines whether ability metadata resolves to MCP exposure. + * + * @since 0.6.0 + * + * @param array $meta Ability metadata. + * + * @return bool True when the metadata resolves to MCP exposure, false otherwise. + */ + public static function is_meta_public( array $meta ): bool { + $mcp_meta = $meta['mcp'] ?? array(); + + // Fail closed when `meta.mcp` is malformed. + if ( ! is_array( $mcp_meta ) ) { + return false; + } + + if ( isset( $mcp_meta['public'] ) ) { + return (bool) $mcp_meta['public']; + } + + return true === ( $meta['public'] ?? false ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityHelperTrait.php b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityHelperTrait.php new file mode 100644 index 0000000000..73ed7a537d --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Abilities/McpAbilityHelperTrait.php @@ -0,0 +1,82 @@ +get_meta(); + $type = $meta['mcp']['type'] ?? 'tool'; + + // Validate type is one of the allowed values + if ( ! in_array( $type, array( 'tool', 'resource', 'prompt' ), true ) ) { + return 'tool'; + } + + return $type; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Autoloader.php b/lib/vendor/wordpress/mcp-adapter/includes/Autoloader.php new file mode 100644 index 0000000000..7b7ba2cf39 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Autoloader.php @@ -0,0 +1,95 @@ + +
+

+ +

+
+ ] + * : The ID of the MCP server to serve. If not specified, uses the first available server. + * + * ## EXAMPLES + * + * # Serve the default MCP server as admin user + * wp mcp serve --user=admin + * + * # Serve a specific server as user with ID 1 + * wp mcp serve --server=my-mcp-server --user=1 + * + * # Serve without authentication (limited capabilities) + * wp mcp serve --server=public-server + * + * @when after_wp_load + * @synopsis [--server=] + */ + public function serve( array $args, array $assoc_args ): void { + + // Get the MCP adapter instance + $adapter = McpAdapter::instance(); + + // Get all registered servers + $servers = $adapter->get_servers(); + + if ( empty( $servers ) ) { + \WP_CLI::error( 'No MCP servers are registered. Please register at least one server first.' ); + } + + // Determine which server to use + $server_id = $assoc_args['server'] ?? null; + $server = null; + + if ( $server_id ) { + $server = $adapter->get_server( $server_id ); + if ( ! $server ) { + \WP_CLI::error( sprintf( 'Server with ID "%s" not found.', $server_id ) ); + } + } else { + // Use the first available server + $server = array_values( $servers )[0]; + $server_id = $server->get_server_id(); + \WP_CLI::debug( sprintf( 'Using server: %s', $server_id ) ); + } + + // Create and start STDIO server bridge + try { + \WP_CLI::debug( sprintf( 'Starting STDIO bridge for server: %s', $server_id ) ); + + // Create STDIO server bridge + $stdio_bridge = new StdioServerBridge( $server ); + + // Start serving (this blocks until terminated) + $stdio_bridge->serve(); + } catch ( \RuntimeException $e ) { + \WP_CLI::error( $e->getMessage() ); + } catch ( \Throwable $e ) { + \WP_CLI::error( 'Failed to start STDIO bridge: ' . $e->getMessage() ); + } + } + + /** + * List all registered MCP servers. + * + * ## OPTIONS + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # List all MCP servers + * wp mcp list + * + * # List servers in JSON format + * wp mcp list --format=json + * + * @when after_wp_load + * @synopsis [--format=] + */ + public function list( array $args, array $assoc_args ): void { + $adapter = McpAdapter::instance(); + + $servers = $adapter->get_servers(); + + if ( empty( $servers ) ) { + \WP_CLI::line( 'No MCP servers registered.' ); + + return; + } + + $items = array(); + foreach ( $servers as $server ) { + $items[] = array( + 'ID' => $server->get_server_id(), + 'Name' => $server->get_server_name(), + 'Version' => $server->get_server_version(), + 'Tools' => count( $server->get_tools() ), + 'Resources' => count( $server->get_resources() ), + 'Prompts' => count( $server->get_prompts() ), + 'Description' => $server->get_server_description(), + ); + } + + $format = $assoc_args['format'] ?? 'table'; + format_items( $format, $items, array( 'ID', 'Name', 'Version', 'Tools', 'Resources', 'Prompts' ) ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Cli/StdioServerBridge.php b/lib/vendor/wordpress/mcp-adapter/includes/Cli/StdioServerBridge.php new file mode 100644 index 0000000000..5552e573e3 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Cli/StdioServerBridge.php @@ -0,0 +1,349 @@ +server = $server; + + // Create request router using server's infrastructure + $this->request_router = $this->create_request_router(); + } + + /** + * Create a request router for the server. + * + * @return \WP\MCP\Transport\Infrastructure\RequestRouter + */ + private function create_request_router(): RequestRouter { + // Create transport context using server's infrastructure + $context = $this->server->create_transport_context(); + + return $context->request_router; + } + + /** + * Start the STDIO server bridge. + * + * This method reads JSON-RPC messages from stdin and writes responses to stdout. + * It runs in a loop until terminated or until it receives a shutdown signal. + * + * @throws \RuntimeException If STDIO transport is disabled. + */ + public function serve(): void { + /** + * Filters whether the STDIO transport is enabled. + * + * Return false to disable STDIO transport entirely. This prevents + * the WP-CLI `mcp-adapter serve` command from functioning. + * + * @since 0.3.0 + * + * @param bool $enabled Whether STDIO transport is enabled. Default true. + */ + $enable_serve = apply_filters( 'mcp_adapter_enable_stdio_transport', true ); + + if ( ! $enable_serve ) { + throw new \RuntimeException( + 'The STDIO transport is disabled. Enable it by setting the "mcp_adapter_enable_stdio_transport" filter to true.' + ); + } + + $this->is_running = true; + + // Log to stderr to keep stdout clean for MCP messages + $this->log_to_stderr( sprintf( 'MCP STDIO Bridge started for server: %s', $this->server->get_server_id() ) ); + + // Main server loop + while ( $this->is_running ) { + try { + // Read a line from stdin (blocking) + $input = fgets( STDIN ); + + if ( false === $input ) { + // EOF or error reading from stdin + break; + } + + // Trim newline delimiter + $input = rtrim( $input, "\r\n" ); + + if ( empty( $input ) ) { + // Empty line, continue reading + continue; + } + + // Process the request and get response + $response = $this->handle_request( $input ); + + // Write response to stdout with newline delimiter + if ( ! empty( $response ) ) { + // Use fwrite() for precise binary-safe JSON-RPC protocol communication. + // WP_CLI output functions would add formatting/prefixes that break MCP protocol. + // MCP requires exact control over stdout for machine-to-machine communication. + fwrite( STDOUT, $response . "\n" ); // phpcs:ignore + fflush( STDOUT ); + } + } catch ( \Throwable $e ) { + // Log errors to stderr + $this->log_to_stderr( 'Error processing request: ' . $e->getMessage() ); + + $error_response = $this->encode_response( + JsonRpcResponseBuilder::create_error_response( + null, + array( + 'code' => McpErrorFactory::INTERNAL_ERROR, + 'message' => 'Internal error', + 'data' => array( + 'details' => $e->getMessage(), + ), + ) + ) + ); + + fwrite( STDOUT, $error_response . "\n" ); // phpcs:ignore + fflush( STDOUT ); + } + } + + $this->log_to_stderr( 'MCP STDIO Bridge stopped' ); + } + + /** + * Log a message to stderr. + * + * @param string $message The message to log. + */ + private function log_to_stderr( string $message ): void { + fwrite( STDERR, "[MCP STDIO Bridge] $message\n" ); // phpcs:ignore + } + + /** + * Handle a JSON-RPC request string and return a JSON-RPC response string. + * + * @param string $json_input The JSON-RPC request string. + * + * @return string The JSON-RPC response string (empty for notifications). + */ + private function handle_request( string $json_input ): string { + try { + // Parse JSON-RPC request + $request = json_decode( $json_input, true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + return $this->create_error_response( + null, + McpErrorFactory::PARSE_ERROR, + 'Parse error', + 'Invalid JSON was received by the server.' + ); + } + + // Validate JSON-RPC structure + if ( ! is_array( $request ) ) { + return $this->create_error_response( + null, + McpErrorFactory::INVALID_REQUEST, + 'Invalid Request', + 'The JSON sent is not a valid Request object.' + ); + } + + // Check for JSON-RPC version + if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) { + return $this->create_error_response( + $request['id'] ?? null, + McpErrorFactory::INVALID_REQUEST, + 'Invalid Request', + 'The JSON-RPC version must be 2.0.' + ); + } + + // Extract request components + $method = $request['method'] ?? null; + $params = $request['params'] ?? array(); + $id = $request['id'] ?? null; + + if ( ! is_string( $method ) ) { + return $this->create_error_response( + $id, + McpErrorFactory::INVALID_REQUEST, + 'Invalid Request', + 'Method must be a string.' + ); + } + + // Convert params to array if it's an object + if ( is_object( $params ) ) { + $params = (array) $params; + } + + if ( ! is_array( $params ) ) { + $params = array(); + } + + // Route the request to the appropriate handler + $result = $this->request_router->route_request( + $method, + $params, + $id, + 'stdio' + ); + + // If this is a notification (no id), don't send a response + if ( null === $id ) { + return ''; + } + + // Format the response + return $this->format_response( $result, $id ); + } catch ( \Throwable $e ) { + // Handle unexpected errors + return $this->create_error_response( + null, + McpErrorFactory::INTERNAL_ERROR, + 'Internal error', + $e->getMessage() + ); + } + } + + /** + * Create a JSON-RPC error response. + * + * @param mixed $id The request ID (can be null). + * @param int $code The error code. + * @param string $message The error message. + * @param string $data Optional error data. + * + * @return string The JSON error response string. + */ + private function create_error_response( $id, int $code, string $message, string $data = '' ): string { + $error = array( + 'code' => $code, + 'message' => $message, + ); + + if ( '' !== $data ) { + $error['data'] = $data; + } + + return $this->encode_response( JsonRpcResponseBuilder::create_error_response( $id, $error ) ); + } + + /** + * Encode a JSON-RPC response to a string. + * + * @param array $response JSON-RPC response structure. + * + * @return string JSON-encoded response string. + */ + private function encode_response( array $response ): string { + $json = wp_json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + + if ( false === $json ) { + // Fallback when JSON encoding fails - use constant for consistency. + return sprintf( + '{"jsonrpc":"2.0","error":{"code":%d,"message":"Internal error"},"id":null}', + McpErrorFactory::INTERNAL_ERROR + ); + } + + return $json; + } + + /** + * Format a handler result as a JSON-RPC response. + * + * @param array $result The handler result. + * @param mixed $id The request ID. + * + * @return string The JSON-RPC response string. + */ + private function format_response( array $result, $id ): string { + // Check if result contains an error + if ( isset( $result['error'] ) ) { + $error = $result['error']; + + // Ensure error has required fields + $error_payload = array( + 'code' => $error['code'] ?? McpErrorFactory::INTERNAL_ERROR, + 'message' => $error['message'] ?? 'Internal error', + ); + + // Add data field if present + if ( isset( $error['data'] ) ) { + $error_payload['data'] = $error['data']; + } + + return $this->encode_response( JsonRpcResponseBuilder::create_error_response( $id, $error_payload ) ); + } + + return $this->encode_response( JsonRpcResponseBuilder::create_success_response( $id, $result ) ); + } + + /** + * Stop the STDIO server bridge. + */ + public function stop(): void { + $this->is_running = false; + } + + /** + * Get the server this bridge is exposing. + * + * @return \WP\MCP\Core\McpServer + */ + public function get_server(): McpServer { + return $this->server; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php new file mode 100644 index 0000000000..41a7afa272 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php @@ -0,0 +1,354 @@ +maybe_create_default_server(); + + /** + * Fires after the MCP Adapter has been initialized. + * + * Use this action to register custom MCP servers. The adapter instance + * provides methods to create and configure additional servers beyond + * the default server. + * + * @since 0.1.0 + * + * @param \WP\MCP\Core\McpAdapter $adapter The MCP Adapter singleton instance. + */ + do_action( 'mcp_adapter_init', $this ); + $this->register_wp_cli_commands(); + self::$initialized = true; + } + + /** + * Conditionally create the default server based on filter. + * + * @internal For use by adapter initialization only. + */ + private function maybe_create_default_server(): void { + /** + * Filters whether the default MCP server should be created. + * + * Return false to prevent the default server from being created. + * This is useful when you want to define custom servers only. + * + * @since 0.3.0 + * + * @param bool $create_default Whether to create the default server. Default true. + */ + if ( ! apply_filters( 'mcp_adapter_create_default_server', true ) ) { + return; + } + + // Register category before abilities + add_action( 'wp_abilities_api_categories_init', array( $this, 'register_default_category' ) ); + add_action( 'wp_abilities_api_init', array( $this, 'register_default_abilities' ) ); + + add_action( 'mcp_adapter_init', array( DefaultServerFactory::class, 'create' ) ); + } + + /** + * Register WP-CLI commands if WP-CLI is available + * + * @internal For use by adapter initialization only. + */ + private function register_wp_cli_commands(): void { + // Only register if WP-CLI is available + if ( ! defined( 'WP_CLI' ) || ! constant( 'WP_CLI' ) ) { + return; + } + + if ( ! class_exists( '\WP_CLI' ) ) { + return; + } + + \WP_CLI::add_command( + 'mcp-adapter', + McpCommand::class, + array( + 'shortdesc' => 'Manage MCP servers via WP-CLI.', + 'longdesc' => 'Commands for managing and serving MCP servers, including STDIO transport.', + ) + ); + } + + /** + * Create and register a new MCP server. + * + * @param string $server_id Unique identifier for the server. + * @param string $server_route_namespace Server route namespace. + * @param string $server_route Server route. + * @param string $server_name Server name. + * @param string $server_description Server description. + * @param string $server_version Server version. + * @param array> $mcp_transports Array of MCP transport class names to initialize. + * @param class-string<\WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface>|null $error_handler The error handler class name. If null, NullMcpErrorHandler will be used. + * @param class-string<\WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface>|null $observability_handler The observability handler class name. If null, NullMcpObservabilityHandler will be used. + * @param list $tools Ability names to register as tools. + * @param list $resources Resources to register. + * @param list $prompts Prompts to register. + * @param callable|null $transport_permission_callback Optional custom permission callback for transport-level authentication. If null, defaults to is_user_logged_in(). + * + * @return \WP\MCP\Core\McpAdapter|\WP_Error McpAdapter instance on success, WP_Error on failure. + */ + public function create_server( string $server_id, string $server_route_namespace, string $server_route, string $server_name, string $server_description, string $server_version, array $mcp_transports, ?string $error_handler, ?string $observability_handler = null, array $tools = array(), array $resources = array(), array $prompts = array(), ?callable $transport_permission_callback = null ) { + // Use NullMcpErrorHandler if no error handler is provided. + if ( ! $error_handler ) { + $error_handler = NullMcpErrorHandler::class; + } + + // Validate error handler class exists and implements McpErrorHandlerInterface. + if ( ! class_exists( $error_handler ) ) { + return new WP_Error( + 'invalid_error_handler', + sprintf( + /* translators: %s: error handler class name */ + esc_html__( 'Error handler class "%s" does not exist.', 'mcp-adapter' ), + esc_html( $error_handler ) + ) + ); + } + + if ( ! in_array( McpErrorHandlerInterface::class, class_implements( $error_handler ) ?: array(), true ) ) { + return new WP_Error( + 'invalid_error_handler', + sprintf( + /* translators: %s: error handler class name */ + esc_html__( 'Error handler class "%s" must implement the McpErrorHandlerInterface.', 'mcp-adapter' ), + esc_html( $error_handler ) + ) + ); + } + + // Use NullMcpObservabilityHandler if no observability handler is provided. + if ( ! $observability_handler ) { + $observability_handler = NullMcpObservabilityHandler::class; + } + + // Validate observability handler class exists and implements McpObservabilityHandlerInterface. + if ( ! class_exists( $observability_handler ) ) { + return new WP_Error( + 'invalid_observability_handler', + sprintf( + /* translators: %s: observability handler class name */ + esc_html__( 'Observability handler class "%s" does not exist.', 'mcp-adapter' ), + esc_html( $observability_handler ) + ) + ); + } + + if ( ! in_array( McpObservabilityHandlerInterface::class, class_implements( $observability_handler ) ?: array(), true ) ) { + return new WP_Error( + 'invalid_observability_handler', + sprintf( + /* translators: %s: observability handler class name */ + esc_html__( 'Observability handler class "%s" must implement the McpObservabilityHandlerInterface interface.', 'mcp-adapter' ), + esc_html( $observability_handler ) + ) + ); + } + + if ( ! doing_action( 'mcp_adapter_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + esc_html__( 'MCP Servers must be created during the "mcp_adapter_init" action. Hook into "mcp_adapter_init" to register your server.', 'mcp-adapter' ), + '0.1.0' + ); + + return new WP_Error( + 'invalid_timing', + esc_html__( 'MCP Server creation must be done during mcp_adapter_init action.', 'mcp-adapter' ) + ); + } + + if ( isset( $this->servers[ $server_id ] ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + // translators: %s: server ID + esc_html__( 'Server with ID "%s" already exists. Each server must have a unique ID.', 'mcp-adapter' ), + esc_html( $server_id ) + ), + '0.1.0' + ); + + return new WP_Error( + 'duplicate_server_id', + // translators: %s: server ID. + sprintf( esc_html__( 'Server with ID "%s" already exists.', 'mcp-adapter' ), esc_html( $server_id ) ) + ); + } + + // Create server with tools, resources, and prompts - let server handle all registration logic. + try { + $server = new McpServer( + $server_id, + $server_route_namespace, + $server_route, + $server_name, + $server_description, + $server_version, + $mcp_transports, + $error_handler, + $observability_handler, + $tools, + $resources, + $prompts, + $transport_permission_callback + ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'server_creation_failed', + sprintf( + /* translators: 1: server ID, 2: error message */ + esc_html__( 'Failed to create server "%1$s": %2$s', 'mcp-adapter' ), + esc_html( $server_id ), + esc_html( $e->getMessage() ) + ) + ); + } + + // Track server creation. + $server->get_observability_handler()->record_event( + 'mcp.server.created', + array( + 'status' => 'success', + 'server_id' => $server_id, + 'transport_count' => count( $mcp_transports ), + 'tools_count' => count( $tools ), + 'resources_count' => count( $resources ), + 'prompts_count' => count( $prompts ), + ) + ); + + // Add server to registry. + $this->servers[ $server_id ] = $server; + + return $this; + } + + /** + * Get a server by ID. + * + * @param string $server_id Server ID. + * + * @return \WP\MCP\Core\McpServer|null + */ + public function get_server( string $server_id ): ?McpServer { + return $this->servers[ $server_id ] ?? null; + } + + /** + * Get all registered servers + * + * @return \WP\MCP\Core\McpServer[] + */ + public function get_servers(): array { + return $this->servers; + } + + /** + * Register the default MCP category. + * + * @return void + */ + public function register_default_category(): void { + wp_register_ability_category( + 'mcp-adapter', + array( + 'label' => 'MCP Adapter', + 'description' => 'Abilities for the MCP Adapter', + ) + ); + } + + /** + * Register the default MCP abilities. + * + * @return void + */ + public function register_default_abilities(): void { + // Register the three core MCP abilities + DiscoverAbilitiesAbility::register(); + GetAbilityInfoAbility::register(); + ExecuteAbilityAbility::register(); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Core/McpComponentRegistry.php b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpComponentRegistry.php new file mode 100644 index 0000000000..9823cce558 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpComponentRegistry.php @@ -0,0 +1,675 @@ + + */ + private array $mcp_tools = array(); + + /** + * MCP resources keyed by resource URI. + * + * @var array + */ + private array $mcp_resources = array(); + + /** + * MCP prompts keyed by prompt name. + * + * @var array + */ + private array $mcp_prompts = array(); + + /** + * MCP Server instance. + * + * @var \WP\MCP\Core\McpServer + */ + private McpServer $mcp_server; + + /** + * Error handler instance. + * + * @var \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface + */ + private McpErrorHandlerInterface $error_handler; + + /** + * Observability handler instance. + * + * @var \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface + */ + private McpObservabilityHandlerInterface $observability_handler; + + /** + * Whether to record component registration. + * + * @var bool + */ + private bool $should_record_component_registration; + + /** + * Constructor. + * + * @param \WP\MCP\Core\McpServer $mcp_server MCP server instance. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface $error_handler Error handler instance. + * @param \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface $observability_handler Observability handler instance. + */ + public function __construct( + McpServer $mcp_server, + McpErrorHandlerInterface $error_handler, + McpObservabilityHandlerInterface $observability_handler + ) { + $this->mcp_server = $mcp_server; + $this->error_handler = $error_handler; + $this->observability_handler = $observability_handler; + + /** + * Filters whether component registration events should be recorded for observability. + * + * Default is false to avoid polluting observability logs during startup. + * Enable this filter to track tool, resource, and prompt registrations + * for debugging or monitoring purposes. + * + * @since 0.3.0 + * + * @param bool $should_record Whether to record component registration events. Default false. + * @param string $server_id The server ID for which components are being registered. + * @param \WP\MCP\Core\McpServer $server The McpServer instance owning the registry. + */ + $this->should_record_component_registration = apply_filters( + 'mcp_adapter_observability_record_component_registration', + false, + $this->mcp_server->get_server_id(), + $this->mcp_server + ); + } + + /** + * Register tools to the server. + * + * @param list $tools Array of ability names (strings) or McpTool instances. + * + * @return void + */ + public function register_tools( array $tools ): void { + foreach ( $tools as $tool_item ) { + $this->register_single_tool( $tool_item ); + } + } + + /** + * Register a single tool to the server. + * + * @param string|\WP\MCP\Domain\Tools\McpTool $tool_item The tool to register. + * + * @return void + */ + private function register_single_tool( $tool_item ): void { + // Case 0: McpTool instance. + if ( $tool_item instanceof McpTool ) { + $this->add_mcp_tool( $tool_item ); + + /** @var \WP\McpSchema\Server\Tools\DTO\Tool $tool_dto */ + $tool_dto = $tool_item->get_protocol_dto(); + $this->track_registration( 'tool', $tool_dto->getName(), 'success' ); + + return; + } + + // Case 1: String - treat as ability name. + if ( is_string( $tool_item ) ) { + $this->register_ability_tool( $tool_item ); + + return; + } + + $this->error_handler->log( + sprintf( + 'Invalid tool registration item: expected McpTool instance or string ability name, got %s.', + is_object( $tool_item ) ? get_class( $tool_item ) : gettype( $tool_item ) + ), + array( 'McpComponentRegistry::register_single_tool' ), + 'warning' + ); + } + + /** + * Register an McpTool directly. + * + * @param \WP\MCP\Domain\Tools\McpTool $mcp_tool McpTool instance. + * + * @return void + * @since 0.3.0 + * + */ + private function add_mcp_tool( McpTool $mcp_tool ): void { + /** @var \WP\McpSchema\Server\Tools\DTO\Tool $tool_dto */ + $tool_dto = $mcp_tool->get_protocol_dto(); + $tool_name = $tool_dto->getName(); + + if ( isset( $this->mcp_tools[ $tool_name ] ) ) { + $this->error_handler->log( + "Tool with name '{$tool_name}' already registered, skipping duplicate.", + array( 'McpComponentRegistry::add_mcp_tool' ), + 'warning' + ); + + return; + } + + $this->mcp_tools[ $tool_name ] = $mcp_tool; + } + + /** + * Record a component registration event. + * + * @param string $type Component type. + * @param string $name Component name. + * @param string $status Registration status ('success' or 'failed'). + * @param array $extra Extra event data. + * + * @return void + */ + private function track_registration( string $type, string $name, string $status, array $extra = array() ): void { + if ( ! $this->should_record_component_registration ) { + return; + } + + $event_data = array_merge( + array( + 'status' => $status, + 'component_type' => $type, + 'component_name' => $name, + 'server_id' => $this->mcp_server->get_server_id(), + ), + $extra + ); + + $this->observability_handler->record_event( 'mcp.component.registration', $event_data ); + } + + /** + * Register a tool from an ability name. + * + * @param string $ability_name Ability name. + * + * @return void + */ + private function register_ability_tool( string $ability_name ): void { + $ability = \wp_get_ability( $ability_name ); + + if ( ! $ability ) { + $this->error_handler->log( "WordPress ability '{$ability_name}' does not exist.", array( "RegisterAbilityAsMcpTool::{$ability_name}" ) ); + $this->track_registration( 'ability_tool', $ability_name, 'failed', array( 'failure_reason' => FailureReason::ABILITY_NOT_FOUND ) ); + + return; + } + + $mcp_tool = McpTool::fromAbility( $ability ); + + if ( is_wp_error( $mcp_tool ) ) { + $this->error_handler->log( $mcp_tool->get_error_message(), array( "McpTool::fromAbility::{$ability_name}" ) ); + $this->track_registration( + 'ability_tool', + $ability_name, + 'failed', + array( 'error_code' => $mcp_tool->get_error_code() ) + ); + + return; + } + + $this->add_mcp_tool( $mcp_tool ); + $this->track_registration( 'ability_tool', $ability_name, 'success' ); + } + + /** + * Register resources to the server. + * + * @param list $resources Array of ability names or McpResource instances. + * + * @return void + */ + public function register_resources( array $resources ): void { + foreach ( $resources as $resource_item ) { + $this->register_single_resource( $resource_item ); + } + } + + /** + * Register a single resource to the server. + * + * @param string|\WP\MCP\Domain\Resources\McpResource $resource_item The resource to register. + * + * @return void + */ + private function register_single_resource( $resource_item ): void { + // Case 0: McpResource instance. + if ( $resource_item instanceof McpResource ) { + $this->add_mcp_resource( $resource_item ); + + /** @var \WP\McpSchema\Server\Resources\DTO\Resource $resource_dto */ + $resource_dto = $resource_item->get_protocol_dto(); + $this->track_registration( 'resource', $resource_dto->getUri(), 'success' ); + + return; + } + + // Case 1: String - treat as ability name. + if ( is_string( $resource_item ) ) { + $this->register_ability_resource( $resource_item ); + + return; + } + + // Case 2: Invalid type. + $this->error_handler->log( + sprintf( + 'Invalid resource registration item: expected McpResource instance or string ability name, got %s.', + is_object( $resource_item ) ? get_class( $resource_item ) : gettype( $resource_item ) + ), + array( 'McpComponentRegistry::register_single_resource' ), + 'warning' + ); + } + + /** + * Register an McpResource directly. + * + * @param \WP\MCP\Domain\Resources\McpResource $mcp_resource McpResource instance. + * + * @return bool True if the resource was added, false if it was a duplicate. + * @since 0.3.0 + * + */ + private function add_mcp_resource( McpResource $mcp_resource ): bool { + /** @var \WP\McpSchema\Server\Resources\DTO\Resource $resource_dto */ + $resource_dto = $mcp_resource->get_protocol_dto(); + $uri = $resource_dto->getUri(); + + if ( isset( $this->mcp_resources[ $uri ] ) ) { + $this->error_handler->log( + "Resource with URI '{$uri}' already registered, skipping duplicate.", + array( 'McpComponentRegistry::add_mcp_resource' ), + 'warning' + ); + + return false; + } + + $this->mcp_resources[ $uri ] = $mcp_resource; + + return true; + } + + /** + * Register an ability-backed resource by ability name. + * + * @param string $ability_name Ability name. + * + * @return void + */ + private function register_ability_resource( string $ability_name ): void { + $ability = \wp_get_ability( $ability_name ); + + if ( ! $ability ) { + $this->error_handler->log( "WordPress ability '{$ability_name}' does not exist.", array( "RegisterAbilityAsMcpResource::{$ability_name}" ) ); + + $this->track_registration( 'resource', $ability_name, 'failed', array( 'failure_reason' => FailureReason::ABILITY_NOT_FOUND ) ); + + return; + } + + $mcp_resource = McpResource::fromAbility( $ability, $this->error_handler ); + + // Check if resource creation returned an error. + if ( is_wp_error( $mcp_resource ) ) { + $this->error_handler->log( $mcp_resource->get_error_message(), array( "McpResource::fromAbility::{$ability_name}" ) ); + + $this->track_registration( + 'resource', + $ability_name, + 'failed', + array( 'error_code' => $mcp_resource->get_error_code() ) + ); + + return; + } + + $added = $this->add_mcp_resource( $mcp_resource ); + + if ( $added ) { + $this->track_registration( 'resource', $ability_name, 'success' ); + } else { + /** @var \WP\McpSchema\Server\Resources\DTO\Resource $resource_dto */ + $resource_dto = $mcp_resource->get_protocol_dto(); + $this->track_registration( + 'resource', + $ability_name, + 'failed', + array( + 'failure_reason' => FailureReason::DUPLICATE_URI, + 'duplicate_uri' => $resource_dto->getUri(), + ) + ); + } + } + + /** + * Register prompts to the server. + * + * Accepts multiple formats: + * - McpPrompt instances + * - Class name string implementing McpPromptBuilderInterface (instantiated automatically) + * - Ability name string (converted via RegisterAbilityAsMcpPrompt) + * - McpPromptBuilderInterface instance (fluent API or custom builders) + * - Array configuration (converted via McpPrompt::fromArray()) + * + * @param list $prompts Array of prompts to register. + * + * @return void + */ + public function register_prompts( array $prompts ): void { + foreach ( $prompts as $prompt_item ) { + $this->register_single_prompt( $prompt_item ); + } + } + + /** + * Register a single prompt to the server. + * + * @param string|\WP\MCP\Domain\Prompts\McpPrompt|\WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface $prompt_item The prompt to register. + * + * @return void + */ + private function register_single_prompt( $prompt_item ): void { + // Case 0: McpPrompt instance. + if ( $prompt_item instanceof McpPrompt ) { + $this->add_mcp_prompt( $prompt_item ); + + /** @var \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt_dto */ + $prompt_dto = $prompt_item->get_protocol_dto(); + $this->track_registration( 'prompt', $prompt_dto->getName(), 'success' ); + + return; + } + + // Case 1: McpPromptBuilderInterface instance (fluent API or custom builder). + if ( $prompt_item instanceof McpPromptBuilderInterface ) { + $this->register_builder_instance( $prompt_item ); + + return; + } + + // Case 2: String - either a class name or ability name. + if ( is_string( $prompt_item ) ) { + // Check if it's a class that implements McpPromptBuilderInterface. + if ( class_exists( $prompt_item ) && in_array( McpPromptBuilderInterface::class, class_implements( $prompt_item ) ?: array(), true ) ) { + $this->register_builder_class( $prompt_item ); + + return; + } + + // Treat as ability name. + $this->register_ability_prompt( $prompt_item ); + + return; + } + + // Case 3: Invalid type. + $this->error_handler->log( + sprintf( + 'Invalid prompt registration item: expected McpPrompt, McpPromptBuilderInterface, or string, got %s.', + is_object( $prompt_item ) ? get_class( $prompt_item ) : gettype( $prompt_item ) + ), + array( 'McpComponentRegistry::register_single_prompt' ), + 'warning' + ); + } + + /** + * Add an McpPrompt to the registry. + * + * @param \WP\MCP\Domain\Prompts\McpPrompt $mcp_prompt McpPrompt instance. + * + * @return void + * @since 0.3.0 + * + */ + private function add_mcp_prompt( McpPrompt $mcp_prompt ): void { + /** @var \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt */ + $prompt = $mcp_prompt->get_protocol_dto(); + $prompt_name = $prompt->getName(); + + if ( isset( $this->mcp_prompts[ $prompt_name ] ) ) { + $this->error_handler->log( + "Prompt with name '{$prompt_name}' already registered, skipping duplicate.", + array( 'McpComponentRegistry::add_mcp_prompt' ), + 'warning' + ); + + return; + } + + $this->mcp_prompts[ $prompt_name ] = $mcp_prompt; + } + + /** + * Register a McpPromptBuilderInterface instance. + * + * @param \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface $builder The builder instance. + * + * @return void + */ + private function register_builder_instance( McpPromptBuilderInterface $builder ): void { + $prompt_name = $builder->get_name(); + + $mcp_prompt = McpPrompt::fromBuilder( $builder ); + if ( $mcp_prompt instanceof WP_Error ) { + $this->error_handler->log( $mcp_prompt->get_error_message(), array( "McpPrompt::fromBuilder::{$prompt_name}" ) ); + + $this->track_registration( + 'prompt', + $prompt_name, + 'failed', + array( 'error_code' => $mcp_prompt->get_error_code() ) + ); + + return; + } + + $this->add_mcp_prompt( $mcp_prompt ); + + $this->track_registration( 'prompt', $prompt_name, 'success' ); + } + + /** + * Register a prompt from a builder class name. + * + * @param string $class_name The fully-qualified class name. + * + * @return void + */ + private function register_builder_class( string $class_name ): void { + try { + /** @var \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface $builder */ + $builder = new $class_name(); + $this->register_builder_instance( $builder ); + } catch ( \Throwable $e ) { + $this->error_handler->log( "Failed to build prompt from class '{$class_name}': {$e->getMessage()}", array( "McpPromptBuilder::{$class_name}" ) ); + + $this->track_registration( 'prompt', $class_name, 'failed', array( 'failure_reason' => FailureReason::BUILDER_EXCEPTION ) ); + } + } + + /** + * Register a prompt from an ability name. + * + * @param string $ability_name The ability name. + * + * @return void + */ + private function register_ability_prompt( string $ability_name ): void { + $ability = \wp_get_ability( $ability_name ); + + if ( ! $ability ) { + $this->error_handler->log( "WordPress ability '{$ability_name}' does not exist.", array( "RegisterAbilityAsMcpPrompt::{$ability_name}" ) ); + + $this->track_registration( 'prompt', $ability_name, 'failed', array( 'failure_reason' => FailureReason::ABILITY_NOT_FOUND ) ); + + return; + } + + $mcp_prompt = McpPrompt::fromAbility( $ability ); + + if ( is_wp_error( $mcp_prompt ) ) { + $this->error_handler->log( $mcp_prompt->get_error_message(), array( "McpPrompt::fromAbility::{$ability_name}" ) ); + + $this->track_registration( + 'prompt', + $ability_name, + 'failed', + array( 'error_code' => $mcp_prompt->get_error_code() ) + ); + + return; + } + + $this->add_mcp_prompt( $mcp_prompt ); + + $this->track_registration( 'prompt', $ability_name, 'success' ); + } + + /** + * Get all tools registered to the server. + * + * @return array + */ + public function get_tools(): array { + return array_map( + static fn( McpTool $mcp_tool ): ToolDto => $mcp_tool->get_protocol_dto(), + $this->mcp_tools + ); + } + + /** + * Get all resources registered to the server. + * + * @return array + */ + public function get_resources(): array { + return array_map( + static fn( McpResource $mcp_resource ): ResourceDto => $mcp_resource->get_protocol_dto(), + $this->mcp_resources + ); + } + + /** + * Get all prompts registered to the server. + * + * @return array + */ + public function get_prompts(): array { + return array_map( + static fn( McpPrompt $mcp_prompt ): PromptDto => $mcp_prompt->get_protocol_dto(), + $this->mcp_prompts + ); + } + + /** + * Get a specific McpTool by tool name. + * + * @param string $tool_name Tool name. + * + * @return \WP\MCP\Domain\Tools\McpTool|null + * @since 0.3.0 + * + */ + public function get_mcp_tool( string $tool_name ): ?McpTool { + return $this->mcp_tools[ $tool_name ] ?? null; + } + + /** + * Get a specific McpResource by URI. + * + * @param string $resource_uri Resource URI. + * + * @return \WP\MCP\Domain\Resources\McpResource|null + * @internal + * @since 0.3.0 + * + */ + public function get_mcp_resource( string $resource_uri ): ?McpResource { + if ( isset( $this->mcp_resources[ $resource_uri ] ) ) { + return $this->mcp_resources[ $resource_uri ]; + } + + // URI schemes are case-insensitive (RFC 3986 §3.1): clients that lowercase + // the scheme would otherwise miss a resource advertised with mixed case. + $folded = McpValidator::fold_uri_scheme( $resource_uri ); + foreach ( $this->mcp_resources as $stored_uri => $mcp_resource ) { + if ( McpValidator::fold_uri_scheme( (string) $stored_uri ) === $folded ) { + return $mcp_resource; + } + } + + return null; + } + + /** + * Get an McpPrompt by prompt name. + * + * @param string $prompt_name Prompt name. + * + * @return \WP\MCP\Domain\Prompts\McpPrompt|null + * @internal + * @since 0.3.0 + * + */ + public function get_mcp_prompt( string $prompt_name ): ?McpPrompt { + return $this->mcp_prompts[ $prompt_name ] ?? null; + } + + /** + * Get a prompt builder instance by prompt name (builder-based prompts). + * + * @param string $prompt_name Prompt name. + * + * @return \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface|null + */ + public function get_prompt_builder( string $prompt_name ): ?McpPromptBuilderInterface { + $mcp_prompt = $this->mcp_prompts[ $prompt_name ] ?? null; + + return $mcp_prompt ? $mcp_prompt->get_builder() : null; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Core/McpServer.php b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpServer.php new file mode 100644 index 0000000000..a83598dee4 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpServer.php @@ -0,0 +1,448 @@ +> $mcp_transports Array of MCP transport class names to initialize (e.g., [McpRestTransport::class]). + * @param class-string<\WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface>|null $error_handler Error handler class to use (e.g., NullMcpErrorHandler::class). Must implement McpErrorHandlerInterface. If null, NullMcpErrorHandler will be used. + * @param class-string<\WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface>|null $observability_handler Observability handler class to use (e.g., NullMcpObservabilityHandler::class). Must implement McpObservabilityHandlerInterface. If null, NullMcpObservabilityHandler will be used. + * @param list $tools Optional ability names to register as tools during construction. + * @param list $resources Optional resources to register during construction. + * @param list $prompts Optional prompts to register during construction. + * @param callable|null $transport_permission_callback Optional custom permission callback for transport-level authentication. If null, defaults to is_user_logged_in(). + * + * @throws \Exception Thrown if the MCP transport class does not extend AbstractMcpTransport. + */ + public function __construct( + string $server_id, + string $server_route_namespace, + string $server_route, + string $server_name, + string $server_description, + string $server_version, + array $mcp_transports, + ?string $error_handler, + ?string $observability_handler, + array $tools = array(), + array $resources = array(), + array $prompts = array(), + ?callable $transport_permission_callback = null + ) { + // Store server configuration + $this->server_id = $server_id; + $this->server_route_namespace = $server_route_namespace; + $this->server_route = $server_route; + $this->server_name = $server_name; + $this->server_description = $server_description; + $this->server_version = $server_version; + $this->transport_permission_callback = $transport_permission_callback; + + /** + * Filters whether MCP protocol validation is enabled for a server. + * + * Validation is disabled by default for performance, as the Abilities API + * also validates all abilities. Enable this filter for stricter MCP protocol + * compliance checking during development or debugging. + * + * @since 0.3.0 + * + * @param bool $enabled Whether validation is enabled. Default false. + * @param string $server_id The server ID being configured. + * @param \WP\MCP\Core\McpServer $server The McpServer instance being constructed. + */ + $this->mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false, $this->server_id, $this ); + + // Setup handlers and components + $this->setup_handlers( $error_handler, $observability_handler ); + $this->setup_components( $tools, $resources, $prompts, $mcp_transports ); + } + + /** + * Setup error and observability handlers. + * + * @param string|null $error_handler Error handler class name. + * @param string|null $observability_handler Observability handler class name. + */ + private function setup_handlers( ?string $error_handler, ?string $observability_handler ): void { + // Instantiate error handler + if ( $error_handler && class_exists( $error_handler ) ) { + /** @var \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface $handler */ + $handler = new $error_handler(); + $this->error_handler = $handler; + } else { + $this->error_handler = new NullMcpErrorHandler(); + } + + // Instantiate observability handler + if ( $observability_handler && class_exists( $observability_handler ) ) { + /** @var \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface $handler */ + $handler = new $observability_handler(); + $this->observability_handler = $handler; + } else { + $this->observability_handler = new NullMcpObservabilityHandler(); + } + } + + /** + * Setup component registry and transport factory. + * + * @param list $tools Tools to register. + * @param list $resources Resources to register. + * @param list $prompts Prompts to register. + * @param array> $mcp_transports Transport classes to initialize. + * + * @throws \Exception + */ + private function setup_components( array $tools, array $resources, array $prompts, array $mcp_transports ): void { + // Initialize component registry + $this->component_registry = new McpComponentRegistry( + $this, + $this->error_handler, + $this->observability_handler + ); + + // Initialize transport factory + $this->transport_factory = new McpTransportFactory( $this ); + + // Register tools, resources, and prompts + $this->register_mcp_components( $tools, $resources, $prompts ); + + // Initialize transports + $this->transport_factory->initialize_transports( $mcp_transports ); + } + + /** + * Register initial tools, resources, and prompts. + * + * @param list $tools Tools to register. + * @param list $resources Resources to register. + * @param list $prompts Prompts to register. + */ + private function register_mcp_components( array $tools, array $resources, array $prompts ): void { + // Register tools if provided + if ( ! empty( $tools ) ) { + $this->component_registry->register_tools( $tools ); + } + + // Register resources if provided + if ( ! empty( $resources ) ) { + $this->component_registry->register_resources( $resources ); + } + + // Register prompts if provided + if ( empty( $prompts ) ) { + return; + } + + $this->component_registry->register_prompts( $prompts ); + } + + /** + * Get server ID. + * + * @return string + */ + public function get_server_id(): string { + return $this->server_id; + } + + /** + * Get server route namespace. + * + * @return string + */ + public function get_server_route_namespace(): string { + return $this->server_route_namespace; + } + + /** + * Get server route. + * + * @return string + */ + public function get_server_route(): string { + return $this->server_route; + } + + /** + * Get the server name. + * + * @return string + */ + public function get_server_name(): string { + return $this->server_name; + } + + /** + * Get server description. + * + * @return string + */ + public function get_server_description(): string { + return $this->server_description; + } + + /** + * Get server version. + * + * @return string + */ + public function get_server_version(): string { + return $this->server_version; + } + + /** + * Get the transport permission callback. + * + * @return callable|null + */ + public function get_transport_permission_callback(): ?callable { + return $this->transport_permission_callback; + } + + /** + * Get the observability handler instance. + * + * @return \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface + */ + public function get_observability_handler(): McpObservabilityHandlerInterface { + return $this->observability_handler; + } + + /** + * Get the error handler instance. + * + * @since 0.5.0 + * + * @return \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface + */ + public function get_error_handler(): McpErrorHandlerInterface { + return $this->error_handler; + } + + /** + * Get all tools registered to this server. + * + * @return array + */ + public function get_tools(): array { + return $this->component_registry->get_tools(); + } + + /** + * Get all resources registered to this server. + * + * @return array + */ + public function get_resources(): array { + return $this->component_registry->get_resources(); + } + + /** + * Get all prompts registered to this server. + * + * @return array + */ + public function get_prompts(): array { + return $this->component_registry->get_prompts(); + } + + /** + * Get a specific McpTool by name. + * + * @param string $tool_name Tool name. + * + * @return \WP\MCP\Domain\Tools\McpTool|null + * @internal + * @since 0.3.0 + * + */ + public function get_mcp_tool( string $tool_name ): ?McpTool { + return $this->component_registry->get_mcp_tool( $tool_name ); + } + + /** + * Get a specific McpResource by URI. + * + * @param string $resource_uri Resource URI. + * + * @return \WP\MCP\Domain\Resources\McpResource|null + * @internal + * @since 0.3.0 + * + */ + public function get_mcp_resource( string $resource_uri ): ?McpResource { + return $this->component_registry->get_mcp_resource( $resource_uri ); + } + + /** + * Get a specific prompt by name. + * + * @param string $prompt_name Prompt name. + * + * @return \WP\McpSchema\Server\Prompts\DTO\Prompt|null + */ + public function get_prompt( string $prompt_name ): ?PromptDto { + $mcp_prompt = $this->component_registry->get_mcp_prompt( $prompt_name ); + + return $mcp_prompt ? $mcp_prompt->get_protocol_dto() : null; + } + + /** + * Get an McpPrompt by name. + * + * @param string $prompt_name Prompt name. + * + * @return \WP\MCP\Domain\Prompts\McpPrompt|null + * @internal + * @since 0.3.0 + * + */ + public function get_mcp_prompt( string $prompt_name ): ?McpPrompt { + return $this->component_registry->get_mcp_prompt( $prompt_name ); + } + + /** + * Get a prompt builder instance by prompt name (builder-based prompts). + * + * @param string $prompt_name Prompt name. + * + * @return \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface|null + */ + public function get_prompt_builder( string $prompt_name ): ?McpPromptBuilderInterface { + return $this->component_registry->get_prompt_builder( $prompt_name ); + } + + /** + * Create transport context with all required dependencies. + * + * @return \WP\MCP\Transport\Infrastructure\McpTransportContext + */ + public function create_transport_context(): McpTransportContext { + return $this->transport_factory->create_transport_context(); + } + + /** + * Check if MCP validation is enabled. + * + * @return bool + */ + public function is_mcp_validation_enabled(): bool { + return $this->mcp_validation_enabled; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Core/McpTransportFactory.php b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpTransportFactory.php new file mode 100644 index 0000000000..3dd8b0681c --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpTransportFactory.php @@ -0,0 +1,119 @@ +mcp_server = $mcp_server; + } + + /** + * Initialize MCP transports for the server. + * + * @param array> $mcp_transports Array of MCP transport class names to initialize. + */ + public function initialize_transports( array $mcp_transports ): void { + foreach ( $mcp_transports as $mcp_transport ) { + // Check if the class exists + if ( ! class_exists( $mcp_transport ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: Transport class name */ + esc_html__( 'Transport class "%s" does not exist. Make sure the class is properly autoloaded or included.', 'mcp-adapter' ), + esc_html( $mcp_transport ) + ), + '0.1.0' + ); + // Log error and continue processing other transports + $this->mcp_server->get_error_handler()->log( + sprintf( 'Transport class "%s" does not exist.', $mcp_transport ), + array( 'McpTransportFactory::initialize_transports' ) + ); + continue; + } + + // Check for interface implementation + if ( ! in_array( McpTransportInterface::class, class_implements( $mcp_transport ) ?: array(), true ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: Transport class name */ + esc_html__( 'Transport class "%s" must implement McpTransportInterface. Check your transport implementation.', 'mcp-adapter' ), + esc_html( $mcp_transport ) + ), + '0.1.0' + ); + // Log error and continue processing other transports + $this->mcp_server->get_error_handler()->log( + sprintf( 'MCP transport class "%s" must implement the McpTransportInterface.', $mcp_transport ), + array( 'McpTransportFactory::initialize_transports' ) + ); + continue; + } + + // Interface-based instantiation with dependency injection + $context = $this->create_transport_context(); + new $mcp_transport( $context ); + } + } + + /** + * Create the transport context with all required dependencies. + * + * @return \WP\MCP\Transport\Infrastructure\McpTransportContext + */ + public function create_transport_context(): McpTransportContext { + // Create handlers + $initialize_handler = new InitializeHandler( $this->mcp_server ); + $tools_handler = new ToolsHandler( $this->mcp_server ); + $resources_handler = new ResourcesHandler( $this->mcp_server ); + $prompts_handler = new PromptsHandler( $this->mcp_server ); + $system_handler = new SystemHandler(); + + // Create the context - the router will be created automatically + return new McpTransportContext( + array( + 'mcp_server' => $this->mcp_server, + 'initialize_handler' => $initialize_handler, + 'tools_handler' => $tools_handler, + 'resources_handler' => $resources_handler, + 'prompts_handler' => $prompts_handler, + 'system_handler' => $system_handler, + 'observability_handler' => $this->mcp_server->get_observability_handler(), + 'error_handler' => $this->mcp_server->get_error_handler(), + 'transport_permission_callback' => $this->mcp_server->get_transport_permission_callback(), + ) + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Core/McpVersionNegotiator.php b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpVersionNegotiator.php new file mode 100644 index 0000000000..148bc698f9 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Core/McpVersionNegotiator.php @@ -0,0 +1,68 @@ + + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- False positive: sniff mistakes array() commas for multi-const commas (only handles short syntax). + public const SUPPORTED_PROTOCOL_VERSIONS = array( + '2025-11-25', + '2025-06-18', + '2024-11-05', + ); + + /** + * Negotiate the protocol version to use for a session. + * + * If the client-requested version is in the supported list it is echoed + * back verbatim. Otherwise the latest supported version is returned. + * + * @since 0.5.0 + * + * @param string $client_version The protocol version requested by the client. + * + * @return string The negotiated protocol version. + */ + public static function negotiate( string $client_version ): string { + if ( in_array( $client_version, self::SUPPORTED_PROTOCOL_VERSIONS, true ) ) { + return $client_version; + } + + return self::SUPPORTED_PROTOCOL_VERSIONS[0]; + } + + /** + * Check whether a given version string is supported. + * + * @since 0.5.0 + * + * @param string $version The protocol version to check. + * + * @return bool True when the version is in the supported list, false otherwise. + */ + public static function is_supported( string $version ): bool { + return in_array( $version, self::SUPPORTED_PROTOCOL_VERSIONS, true ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Contracts/McpComponentInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Contracts/McpComponentInterface.php new file mode 100644 index 0000000000..a3df5915cc --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Contracts/McpComponentInterface.php @@ -0,0 +1,93 @@ + Internal metadata. + * @since 0.5.0 + * + */ + public function get_adapter_meta(): array; + + /** + * Get observability context tags for logging/metrics. + * + * This replaces legacy approaches that derived observability tags from DTO `_meta`. + * + * @return array Observability tags (component_type, source, etc.). + * @since 0.5.0 + * + */ + public function get_observability_context(): array; +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/Contracts/McpPromptBuilderInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/Contracts/McpPromptBuilderInterface.php new file mode 100644 index 0000000000..cec00fc63c --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/Contracts/McpPromptBuilderInterface.php @@ -0,0 +1,74 @@ + 'code-review', + * 'title' => 'Code Review', + * 'description' => 'Generate a comprehensive code review', + * 'arguments' => [ + * ['name' => 'code', 'description' => 'The code to review', 'required' => true], + * ], + * 'handler' => fn($args) => ['messages' => [...]], + * 'permission' => fn() => true, + * ]); + * ``` + * + * 2. From WordPress Ability (ability-backed): + * ```php + * $prompt = McpPrompt::fromAbility($ability); + * ``` + * + * 3. From prompt builder (builder-backed compatibility): + * ```php + * $prompt = McpPrompt::fromBuilder($builder); + * ``` + * + * McpPrompt wraps a protocol-only PromptDto for MCP serialization. Internal + * adapter metadata and execution wiring live on this class and are never + * exposed to MCP clients. Use get_protocol_dto() for protocol responses. + * + * @since 0.5.0 + */ +final class McpPrompt implements McpComponentInterface { + + + // ========================================================================= + // Runtime Properties + // ========================================================================= + + /** + * Clean Prompt DTO (protocol-only). + * + * @var \WP\McpSchema\Server\Prompts\DTO\Prompt + */ + private PromptDto $prompt; + + /** + * Ability used for execution/permission checks (ability-backed prompts). + * + * @var \WP_Ability|null + */ + private ?\WP_Ability $ability = null; + + /** + * Builder instance (builder-backed prompts). + * + * @var \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface|null + */ + private ?McpPromptBuilderInterface $builder = null; + + /** + * Direct execution handler (callable-backed prompts). + * + * @var callable|null + */ + private $handler = null; + + /** + * Direct permission callback (callable-backed prompts). + * + * @var callable|null + */ + private $permission_callback = null; + + /** + * Internal adapter metadata (never exposed to clients). + * + * @var array + */ + private array $adapter_meta = array(); + + /** + * Observability context tags for logging/metrics. + * + * @var array + */ + private array $observability_context = array(); + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * Private constructor - use factory methods. + * + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The Prompt DTO. + */ + private function __construct( PromptDto $prompt ) { + $this->prompt = $prompt; + } + + // ========================================================================= + // Factory Methods + // ========================================================================= + + /** + * Create a prompt definition from an array configuration. + * + * @param array $config The prompt configuration array. + * + * @return self|\WP_Error + */ + public static function fromArray( array $config ) { + if ( empty( $config['name'] ) ) { + return new WP_Error( 'mcp_prompt_missing_name', 'Prompt configuration must include a "name" field.' ); + } + + if ( ! isset( $config['handler'] ) || ! is_callable( $config['handler'] ) ) { + return new WP_Error( 'mcp_prompt_missing_handler', 'Prompt configuration must include a callable "handler" field.' ); + } + + // Validate and prepare icons if set. + $valid_icons = null; + if ( isset( $config['icons'] ) && is_array( $config['icons'] ) && ! empty( $config['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $config['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $valid_icons = $icons_result['valid']; + } + } + + $prompt_data = array( + 'name' => $config['name'], + 'description' => $config['description'] ?? null, + ); + + if ( isset( $config['title'] ) ) { + $prompt_data['title'] = $config['title']; + } + + $prompt_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $prompt_meta ) { + $prompt_data['_meta'] = $prompt_meta; + } + + if ( null !== $valid_icons ) { + $prompt_data['icons'] = $valid_icons; + } + + // Create the Prompt DTO - wrap in try-catch since PromptArgument::fromArray() and PromptDto::fromArray() can throw. + try { + // Process arguments inside try-catch since PromptArgument::fromArray() can throw. + if ( isset( $config['arguments'] ) && is_array( $config['arguments'] ) && ! empty( $config['arguments'] ) ) { + $prompt_data['arguments'] = array_map( + static function ( array $arg ): PromptArgument { + return PromptArgument::fromArray( + array( + 'name' => $arg['name'], + 'title' => $arg['title'] ?? null, + 'description' => $arg['description'] ?? null, + 'required' => $arg['required'] ?? null, + ) + ); + }, + $config['arguments'] + ); + } + + $prompt = PromptDto::fromArray( $prompt_data ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_prompt_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Prompt DTO: %s', 'mcp-adapter' ), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpPromptValidator::validate_prompt_dto( $prompt ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + $instance = new self( $prompt ); + $instance->handler = $config['handler']; + + if ( isset( $config['permission'] ) && is_callable( $config['permission'] ) ) { + $instance->permission_callback = $config['permission']; + } + + $instance->observability_context = array( + 'component_type' => 'prompt', + 'prompt_name' => $config['name'], + 'source' => 'array', + ); + + return $instance; + } + + /** + * Create an ability-backed MCP prompt. + * + * @param \WP_Ability $ability WordPress ability. + * + * @return self|\WP_Error + */ + public static function fromAbility( \WP_Ability $ability ) { + $prompt_data = RegisterAbilityAsMcpPrompt::build( $ability ); + if ( $prompt_data instanceof WP_Error ) { + return $prompt_data; + } + + $instance = new self( $prompt_data['prompt'] ); + $instance->adapter_meta = $prompt_data['adapter_meta']; + $instance->ability = $ability; + + $instance->observability_context = array( + 'component_type' => 'prompt', + 'prompt_name' => $prompt_data['prompt']->getName(), + 'ability_name' => $ability->get_name(), + 'source' => 'ability', + ); + + return $instance; + } + + /** + * Create a builder-backed MCP prompt. + * + * @param \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface $builder Builder instance. + * + * @return self|\WP_Error + */ + public static function fromBuilder( McpPromptBuilderInterface $builder ) { + try { + $prompt = $builder->build(); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_prompt_builder_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpPromptValidator::validate_prompt_dto( $prompt ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + $instance = new self( $prompt ); + $instance->builder = $builder; + + $instance->adapter_meta = array( + 'source' => 'builder', + 'builder_class' => get_class( $builder ), + ); + + $instance->observability_context = array( + 'component_type' => 'prompt', + 'prompt_name' => $prompt->getName(), + 'source' => 'builder', + ); + + return $instance; + } + + // ========================================================================= + // McpComponentInterface Implementation + // ========================================================================= + + /** + * Get the clean protocol DTO for MCP responses. + * + * @return \WP\McpSchema\Server\Prompts\DTO\Prompt + */ + public function get_protocol_dto(): PromptDto { + return $this->prompt; + } + + /** + * Execute the prompt. + * + * @param mixed $arguments Prompt arguments. + * + * @return mixed + */ + public function execute( $arguments ) { + $args = $this->unwrap_input_if_needed( $arguments ); + $args = is_array( $args ) ? $args : array(); + + if ( null !== $this->ability ) { + $args = AbilityArgumentNormalizer::normalize( $this->ability, $args ); + + try { + $result = $this->ability->execute( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } elseif ( null !== $this->builder ) { + try { + $result = $this->builder->handle( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } elseif ( null !== $this->handler ) { + try { + $result = call_user_func( $this->handler, $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } else { + return new WP_Error( 'mcp_prompt_no_handler', 'No prompt execution strategy configured.' ); + } + + if ( $result instanceof WP_Error ) { + return $result; + } + + if ( ! is_array( $result ) ) { + $result = array( 'result' => $result ); + } + + return $result; + } + + /** + * Unwrap prompt input arguments when the input schema was transformed (flattened → object wrapper). + * + * @param mixed $arguments Raw prompt arguments. + * + * @return mixed + */ + private function unwrap_input_if_needed( $arguments ) { + $is_transformed = true === ( $this->adapter_meta['input_schema_transformed'] ?? false ); + + if ( ! $is_transformed ) { + return $arguments; + } + + $wrapper = $this->adapter_meta['input_schema_wrapper'] ?? 'input'; + $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'input'; + + return is_array( $arguments ) ? ( $arguments[ $wrapper ] ?? null ) : null; + } + + /** + * Check whether the current request has permission to execute this prompt. + * + * @param mixed $arguments Prompt arguments. + * + * @return bool|\WP_Error + */ + public function check_permission( $arguments ) { + $args = $this->unwrap_input_if_needed( $arguments ); + $args = is_array( $args ) ? $args : array(); + + if ( null !== $this->ability ) { + $args = AbilityArgumentNormalizer::normalize( $this->ability, $args ); + + try { + return $this->ability->check_permissions( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + if ( null !== $this->builder ) { + try { + return $this->builder->has_permission( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + if ( null !== $this->permission_callback ) { + try { + $result = call_user_func( $this->permission_callback, $args ); + + return $result instanceof WP_Error ? $result : (bool) $result; + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + return new WP_Error( + 'mcp_permission_denied', + 'Access denied.', + array( 'failure_reason' => FailureReason::NO_PERMISSION_STRATEGY ) + ); + } + + /** + * Get internal adapter metadata for this prompt. + * + * @return array + */ + public function get_adapter_meta(): array { + return $this->adapter_meta; + } + + /** + * Get observability context tags for logging/metrics. + * + * @return array + */ + public function get_observability_context(): array { + return $this->observability_context; + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + /** + * Get the underlying builder instance, when builder-backed. + * + * @return \WP\MCP\Domain\Prompts\Contracts\McpPromptBuilderInterface|null + */ + public function get_builder(): ?McpPromptBuilderInterface { + return $this->builder; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptBuilder.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptBuilder.php new file mode 100644 index 0000000000..a4724549a3 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptBuilder.php @@ -0,0 +1,345 @@ +title('My Prompt') + * ->argument('input', 'Input text', true) + * ->handler(function(array $args): array { + * return ['messages' => [...]]; + * }); + * + * // Array configuration (WordPress-style) + * $prompt = McpPrompt::fromArray([ + * 'name' => 'my-prompt', + * 'title' => 'My Prompt', + * 'arguments' => [['name' => 'input', 'description' => 'Input text', 'required' => true]], + * 'handler' => function(array $args): array { return [...]; }, + * ]); + * ``` + * + * This class remains functional for backward compatibility but will be + * removed in a future major version. + * + * @see McpPrompt For the preferred prompt creation API. + */ +abstract class McpPromptBuilder implements McpPromptBuilderInterface { + + /** + * The prompt name (unique identifier). + * + * @var string + */ + protected string $name = ''; + + /** + * The prompt title (human-readable display name). + * + * @var string|null + */ + protected ?string $title = null; + + /** + * The prompt description. + * + * @var string|null + */ + protected ?string $description = null; + + /** + * The prompt arguments. + * + * @var list + */ + protected array $arguments = array(); + + /** + * The prompt icons for UI display. + * + * @since 0.5.0 + * + * @var list, theme?: string}>|null + */ + protected ?array $icons = null; + + /** + * Additional metadata for MCP clients. + * + * Use this to attach purpose-specific metadata that MCP clients can consume. + * Key names are kept as written. MCP declares `_meta` an object, so {@see self::build()} + * emits this only when it is a non-empty associative array; a list or an empty array + * would serialize as a JSON array and is omitted instead. + * + * @since 0.5.0 + * + * @var array + */ + protected array $meta = array(); + + /** + * Constructor - automatically configures the prompt. + * + * Configuration happens exactly once during construction, ensuring + * idempotent behavior. Subclasses should NOT override this constructor; + * instead, implement the configure() method. + * + * @since 0.5.0 + */ + final public function __construct() { + $this->configure(); + } + + /** + * Configure the prompt properties. + * + * Subclasses must implement this method to set the name, title, + * description, and arguments for the prompt. This method is called + * exactly once during construction. + * + * @return void + */ + abstract protected function configure(): void; + + /** + * Build and return the Prompt DTO instance. + * + * This method converts the configured state into an MCP Prompt DTO. + * Safe to call multiple times - always returns a fresh DTO based on + * the current (immutable after construction) state. + * + * @return \WP\McpSchema\Server\Prompts\DTO\Prompt The built prompt DTO. + */ + public function build(): PromptDto { + $argument_dtos = null; + if ( ! empty( $this->arguments ) ) { + $argument_dtos = array_map( + static function ( array $arg ): PromptArgument { + return PromptArgument::fromArray( + array( + 'name' => $arg['name'], + 'title' => $arg['title'] ?? null, + 'description' => $arg['description'] ?? null, + 'required' => $arg['required'] ?? null, + ) + ); + }, + $this->arguments + ); + } + + // Validate and prepare icons if set. + $valid_icons = null; + if ( ! empty( $this->icons ) ) { + $icons_result = McpValidator::validate_icons_array( $this->icons ); + if ( ! empty( $icons_result['valid'] ) ) { + $valid_icons = $icons_result['valid']; + } + } + + $prompt_data = array( + 'name' => $this->name, + 'title' => $this->title, + 'description' => $this->description, + 'arguments' => $argument_dtos, + ); + + $prompt_meta = McpValidator::normalize_meta( $this->meta ); + if ( null !== $prompt_meta ) { + $prompt_data['_meta'] = $prompt_meta; + } + + // Only include icons if valid ones exist. + if ( null !== $valid_icons ) { + $prompt_data['icons'] = $valid_icons; + } + + return PromptDto::fromArray( $prompt_data ); + } + + /** + * Get the unique name for this prompt. + * + * @return string The prompt name. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Get the prompt title. + * + * @return string|null The prompt title. + */ + public function get_title(): ?string { + return $this->title; + } + + /** + * Get the prompt description. + * + * @return string|null The prompt description. + */ + public function get_description(): ?string { + return $this->description; + } + + /** + * Get the prompt arguments. + * + * @return list The prompt arguments. + */ + public function get_arguments(): array { + return $this->arguments; + } + + /** + * Get the prompt icons. + * + * @return list, theme?: string}> The prompt icons. + * @since 0.5.0 + * + */ + public function get_icons(): array { + return $this->icons ?? array(); + } + + /** + * Set the prompt icons for UI display. + * + * Icons are validated during build() using McpValidator::validate_icons_array(). + * Invalid icons are filtered out with warnings (graceful degradation). + * + * Per MCP 2025-11-25: + * - MUST support: image/png, image/jpeg, image/jpg + * - SHOULD support: image/svg+xml, image/webp + * + * @param list, theme?: string}> $icons Array of icon definitions. + * + * @return self + * @since 0.5.0 + * + */ + protected function set_icons( array $icons ): self { + $this->icons = $icons; + + return $this; + } + + /** + * Get the additional metadata. + * + * @return array The additional metadata. + * @since 0.5.0 + * + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Set additional metadata. + * + * Key names are kept as written. MCP declares `_meta` an object, so {@see self::build()} + * emits this only when it is a non-empty associative array. + * + * @param array $meta Additional metadata key-value pairs. + * + * @return self + * @since 0.5.0 + * + */ + protected function set_meta( array $meta ): self { + $this->meta = $meta; + + return $this; + } + + /** + * Handle the prompt execution when called. + * + * Subclasses must implement this method to handle the prompt logic. + * + * @param array $arguments The arguments passed to the prompt. + * + * @return array The prompt response. + */ + abstract public function handle( array $arguments ): array; + + /** + * Check if the current user has permission to execute this prompt. + * + * Default implementation allows all executions. Override this method + * to implement custom permission logic. + * + * @param array $arguments The arguments passed to the prompt. + * + * @return bool True if execution is allowed, false otherwise. + */ + public function has_permission( array $arguments ): bool { + // Default: allow all executions + // Override this method to implement custom permission logic + return true; + } + + /** + * Helper method to add an argument to the prompt. + * + * @param string $name The argument name. + * @param string|null $description Optional argument description. + * @param bool $required Whether the argument is required. + * + * @return self + */ + protected function add_argument( string $name, ?string $description = null, bool $required = false ): self { + $this->arguments[] = $this->create_argument( $name, $description, $required ); + + return $this; + } + + /** + * Helper method to create an argument definition. + * + * @param string $name The argument name. + * @param string|null $description Optional argument description. + * @param bool $required Whether the argument is required. + * + * @return array{name: string, description?: string, required?: true} The argument definition. + */ + protected function create_argument( string $name, ?string $description = null, bool $required = false ): array { + $argument = array( + 'name' => $name, + ); + + if ( null !== $description ) { + $argument['description'] = $description; + } + + if ( $required ) { + $argument['required'] = true; + } + + return $argument; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptValidator.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptValidator.php new file mode 100644 index 0000000000..876519e06f --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/McpPromptValidator.php @@ -0,0 +1,502 @@ +getName() ) ) { + $errors[] = __( 'Prompt name must be 1-128 characters and contain only [A-Za-z0-9_.-]', 'mcp-adapter' ); + } + + // Validate icons if present. + $icons = $prompt->getIcons(); + if ( ! empty( $icons ) ) { + $icons_array = array_map( static fn( $icon ) => $icon->toArray(), $icons ); + $icons_result = McpValidator::validate_icons_array( $icons_array ); + $icons_errors = self::format_icon_validation_errors( $icons_result ); + $errors = array_merge( $errors, $icons_errors ); + } + + // Validate annotations if present (shared annotations). + // Currently Prompt DTO doesn't have annotations field in spec, but if it did, we'd validate here. + // BaseMetadata has title and name, handled separately. + + if ( ! empty( $errors ) ) { + return new WP_Error( + 'mcp_prompt_validation_failed', + sprintf( + /* translators: %s: list of validation errors */ + __( 'Prompt validation failed: %s', 'mcp-adapter' ), + implode( '; ', $errors ) + ) + ); + } + + return true; + } + + /** + * Validate an McpPrompt instance against the MCP schema. + * + * @param \WP\MCP\Domain\Prompts\McpPrompt $prompt The prompt instance to validate. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + public static function validate_prompt_instance( McpPrompt $prompt ) { + return self::validate_prompt_dto( $prompt->get_protocol_dto() ); + } + + + /** + * Get validation error details for debugging purposes. + * This is the core validation method - all other validation methods use this. + * + * @param array $prompt_data The prompt data to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_validation_errors( array $prompt_data ): array { + $errors = array(); + + // Check required fields + if ( empty( $prompt_data['name'] ) || ! is_string( $prompt_data['name'] ) || ! McpValidator::validate_name( $prompt_data['name'] ) ) { + $errors[] = __( 'Prompt name is required and must be 1-128 characters and contain only [A-Za-z0-9_.-]', 'mcp-adapter' ); + } + + // Check optional fields if present + if ( isset( $prompt_data['title'] ) && ! is_string( $prompt_data['title'] ) ) { + $errors[] = __( 'Prompt title must be a string if provided', 'mcp-adapter' ); + } + + if ( isset( $prompt_data['description'] ) && ! is_string( $prompt_data['description'] ) ) { + $errors[] = __( 'Prompt description must be a string if provided', 'mcp-adapter' ); + } + + // Validate arguments (optional field) + if ( isset( $prompt_data['arguments'] ) ) { + $arguments_errors = self::get_arguments_validation_errors( $prompt_data['arguments'] ); + if ( ! empty( $arguments_errors ) ) { + $errors = array_merge( $errors, $arguments_errors ); + } + } + + return $errors; + } + + /** + * Get detailed validation errors for prompt arguments. + * + * @param array|mixed $arguments The arguments to validate. + * + * @return array Array of validation errors, empty if valid. + */ + private static function get_arguments_validation_errors( $arguments ): array { + $errors = array(); + + // Arguments must be an array + if ( ! is_array( $arguments ) ) { + return array( __( 'Prompt arguments must be an array if provided', 'mcp-adapter' ) ); + } + + // Validate each argument + foreach ( $arguments as $index => $argument ) { + if ( ! is_array( $argument ) ) { + $errors[] = sprintf( + /* translators: %d: argument index */ + __( 'Prompt argument at index %d must be an object', 'mcp-adapter' ), + $index + ); + continue; + } + + // Check required name field + if ( empty( $argument['name'] ) || ! is_string( $argument['name'] ) ) { + $errors[] = sprintf( + /* translators: %d: argument index */ + __( 'Prompt argument at index %d must have a non-empty name string', 'mcp-adapter' ), + $index + ); + continue; + } + + // Validate argument name format (uses standard 128-char limit per MCP spec). + if ( ! McpValidator::validate_name( $argument['name'] ) ) { + $errors[] = sprintf( + /* translators: %s: argument name */ + __( 'Prompt argument \'%s\' name must only contain letters, numbers, hyphens (-), underscores (_), and dots (.), and be 128 characters or less', 'mcp-adapter' ), + $argument['name'] + ); + } + + // Check optional description field + if ( isset( $argument['description'] ) && ! is_string( $argument['description'] ) ) { + $errors[] = sprintf( + /* translators: %s: argument name */ + __( 'Prompt argument \'%s\' description must be a string if provided', 'mcp-adapter' ), + $argument['name'] + ); + } + + // Check optional required field + if ( ! isset( $argument['required'] ) || is_bool( $argument['required'] ) ) { + continue; + } + + $errors[] = sprintf( + /* translators: %s: argument name */ + __( 'Prompt argument \'%s\' required field must be a boolean if provided', 'mcp-adapter' ), + $argument['name'] + ); + } + + return $errors; + } + + /** + * Validate prompt messages array (used when getting a prompt with messages). + * + * @param array $messages The messages to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function validate_prompt_messages( array $messages ): array { + $errors = array(); + + foreach ( $messages as $index => $message ) { + if ( ! is_array( $message ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message at index %d must be an object', 'mcp-adapter' ), + $index + ); + continue; + } + + // Check the required role field + if ( empty( $message['role'] ) || ! is_string( $message['role'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message at index %d must have a role field', 'mcp-adapter' ), + $index + ); + continue; + } + + // Validate role value + if ( ! in_array( $message['role'], array( 'user', 'assistant' ), true ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message at index %d role must be either \'user\' or \'assistant\'', 'mcp-adapter' ), + $index + ); + } + + // Check the required content field + if ( empty( $message['content'] ) || ! is_array( $message['content'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message at index %d must have a content object', 'mcp-adapter' ), + $index + ); + continue; + } + + // Validate content + $content_errors = self::get_content_validation_errors( $message['content'], $index ); + if ( empty( $content_errors ) ) { + continue; + } + + $errors = array_merge( $errors, $content_errors ); + } + + return $errors; + } + + /** + * Format icon validation errors from the validation result. + * + * @param array{valid: array, errors: array} $icons_result The result from validate_icons_array. + * + * @return array Array of formatted error messages. + */ + private static function format_icon_validation_errors( array $icons_result ): array { + $errors = array(); + + if ( ! empty( $icons_result['errors'] ) ) { + foreach ( $icons_result['errors'] as $error_group ) { + foreach ( $error_group['errors'] as $error ) { + $errors[] = sprintf( + /* translators: 1: icon index, 2: error message */ + __( 'Icon at index %1$d: %2$s', 'mcp-adapter' ), + $error_group['index'], + $error + ); + } + } + } + + return $errors; + } + + /** + * Get validation errors for message content. + * + * @param array $content The content to validate. + * @param int $message_index The message index for error reporting. + * + * @return array Array of validation errors, empty if valid. + */ + private static function get_content_validation_errors( array $content, int $message_index ): array { + $errors = array(); + + // Check the required type field + if ( empty( $content['type'] ) || ! is_string( $content['type'] ) ) { + return array( + sprintf( + /* translators: %d: message index */ + __( 'Message %d content must have a type field', 'mcp-adapter' ), + $message_index + ), + ); + } + + $type = $content['type']; + + switch ( $type ) { + case 'text': + if ( ! isset( $content['text'] ) || ! is_string( $content['text'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d text content must have a text field', 'mcp-adapter' ), + $message_index + ); + } + break; + + case 'image': + if ( empty( $content['data'] ) || ! is_string( $content['data'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d image content must have a data field with base64-encoded image', 'mcp-adapter' ), + $message_index + ); + } elseif ( ! McpValidator::validate_base64( $content['data'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d image content data must be valid base64', 'mcp-adapter' ), + $message_index + ); + } + + // mimeType is required. Its value is not checked. + if ( empty( $content['mimeType'] ) || ! is_string( $content['mimeType'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d image content must have a mimeType field', 'mcp-adapter' ), + $message_index + ); + } + break; + + case 'audio': + if ( empty( $content['data'] ) || ! is_string( $content['data'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d audio content must have a data field with base64-encoded audio', 'mcp-adapter' ), + $message_index + ); + } elseif ( ! McpValidator::validate_base64( $content['data'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d audio content data must be valid base64', 'mcp-adapter' ), + $message_index + ); + } + + // mimeType is required. Its value is not checked. + if ( empty( $content['mimeType'] ) || ! is_string( $content['mimeType'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d audio content must have a mimeType field', 'mcp-adapter' ), + $message_index + ); + } + break; + + case 'resource_link': + // ResourceLink is a metadata-only reference (same shape as Resource + type discriminator). + if ( empty( $content['name'] ) || ! is_string( $content['name'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content must have a name field', 'mcp-adapter' ), + $message_index + ); + } + + if ( empty( $content['uri'] ) || ! is_string( $content['uri'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content must have a uri field', 'mcp-adapter' ), + $message_index + ); + } elseif ( ! McpValidator::validate_resource_uri( $content['uri'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content uri must be a valid URI format', 'mcp-adapter' ), + $message_index + ); + } + + if ( isset( $content['mimeType'] ) && ! is_string( $content['mimeType'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content mimeType must be a string if provided', 'mcp-adapter' ), + $message_index + ); + } + + if ( isset( $content['size'] ) && ! is_int( $content['size'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content size must be an integer if provided', 'mcp-adapter' ), + $message_index + ); + } + + if ( isset( $content['title'] ) && ! is_string( $content['title'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content title must be a string if provided', 'mcp-adapter' ), + $message_index + ); + } + + if ( isset( $content['description'] ) && ! is_string( $content['description'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content description must be a string if provided', 'mcp-adapter' ), + $message_index + ); + } + + if ( isset( $content['icons'] ) ) { + if ( ! is_array( $content['icons'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource_link content icons must be an array if provided', 'mcp-adapter' ), + $message_index + ); + } else { + $icons_result = McpValidator::validate_icons_array( $content['icons'], false ); + $errors = array_merge( $errors, self::format_icon_validation_errors( $icons_result ) ); + } + } + + break; + + case 'resource': + if ( empty( $content['resource'] ) || ! is_array( $content['resource'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d resource content must have a resource object', 'mcp-adapter' ), + $message_index + ); + } else { + // Validate embedded resource using the resource validator for strict MCP compliance. + $resource_errors = McpResourceValidator::get_validation_errors( $content['resource'] ); + foreach ( $resource_errors as $resource_error ) { + $errors[] = sprintf( + /* translators: %1$d: message index, %2$s: resource error */ + __( 'Message %1$d embedded resource: %2$s', 'mcp-adapter' ), + $message_index, + $resource_error + ); + } + } + break; + + default: + $errors[] = sprintf( + /* translators: %1$d: message index, %2$s: content type */ + __( 'Message %1$d content type \'%2$s\' is not supported. Must be \'text\', \'image\', \'audio\', \'resource\', or \'resource_link\'', 'mcp-adapter' ), + $message_index, + $type + ); + break; + } + + // Check optional annotations + if ( isset( $content['annotations'] ) ) { + if ( ! is_array( $content['annotations'] ) ) { + $errors[] = sprintf( + /* translators: %d: message index */ + __( 'Message %d content annotations must be an array if provided', 'mcp-adapter' ), + $message_index + ); + } else { + $annotation_errors = McpValidator::get_annotation_validation_errors( $content['annotations'] ); + if ( ! empty( $annotation_errors ) ) { + $errors = array_merge( $errors, $annotation_errors ); + } + } + } + + return $errors; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php new file mode 100644 index 0000000000..fdcf7ad448 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php @@ -0,0 +1,526 @@ + 'Code Review Prompt', + * 'description' => 'Generate code review prompt', + * 'input_schema' => array( + * 'type' => 'object', + * 'properties' => array( + * 'code' => array('type' => 'string', 'description' => 'Code to review'), + * ), + * 'required' => array('code'), + * ), + * 'meta' => array( + * 'mcp' => array('public' => true, 'type' => 'prompt'), + * 'annotations' => array(...) + * ) + * ) + * ); + * + * @since 0.5.0 + */ +class RegisterAbilityAsMcpPrompt { + + /** + * The WordPress ability instance. + * + * @var \WP_Ability + */ + private \WP_Ability $ability; + + /** + * Tracks whether input_schema was transformed from flattened to object format. + * + * @since 0.5.0 + * + * @var bool + */ + private bool $schema_was_transformed = false; + + /** + * The wrapper property name used when transforming flattened schemas. + * + * @since 0.5.0 + * + * @var string|null + */ + private ?string $schema_wrapper_property = null; + + /** + * Tracks the source of prompt arguments. + * + * Possible values: + * - 'explicit': Arguments came from ability.meta.mcp.arguments + * - 'schema': Arguments were auto-converted from ability.input_schema + * - null: No arguments present + * + * @since 0.5.0 + * + * @var string|null + */ + private ?string $arguments_source = null; + + /** + * Constructor. + * + * @param \WP_Ability $ability The ability. + */ + private function __construct( \WP_Ability $ability ) { + $this->ability = $ability; + } + + /** + * Make a new instance of the class. + * + * @param \WP_Ability $ability The ability. + * + * @return \WP\McpSchema\Server\Prompts\DTO\Prompt|\WP_Error Returns Prompt DTO or WP_Error if validation fails. + */ + public static function make( \WP_Ability $ability ) { + $prompt = new self( $ability ); + + return $prompt->get_prompt(); + } + + /** + * Get the MCP prompt instance. + * + * @return \WP\McpSchema\Server\Prompts\DTO\Prompt|\WP_Error Prompt DTO or WP_Error if validation fails. + * @since 0.5.0 + * + */ + private function get_prompt() { + $built = $this->build_prompt_data(); + + // Propagate WP_Error from argument validation. + if ( is_wp_error( $built ) ) { + return $built; + } + + try { + return PromptDto::fromArray( $built['prompt_data'] ); + } catch ( \Throwable $e ) { + return new WP_Error( 'mcp_prompt_schema_invalid', $e->getMessage() ); + } + } + + /** + * Build Prompt DTO data and adapter metadata. + * + * @return array{prompt_data: array, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + private function build_prompt_data() { + $data = $this->get_data(); + + // Propagate WP_Error from argument validation. + if ( is_wp_error( $data ) ) { + return $data; + } + + // Get ability meta for icons and user _meta extraction. + $ability_meta = $this->ability->get_meta(); + $mcp_meta = $ability_meta['mcp'] ?? array(); + + // Map icons from ability.meta.mcp.icons if present. + // Uses same pattern as tools/resources for consistency. + if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $data['icons'] = $icons_result['valid']; + } + } + + // Build adapter metadata, tracking transformation when it occurred. + $adapter_meta = array( + 'ability' => $this->ability->get_name(), + ); + + // Track arguments source when arguments are present. + if ( null !== $this->arguments_source ) { + $adapter_meta['arguments_source'] = $this->arguments_source; + } + + // Record transformation metadata when schema was wrapped (matches tool behavior). + // Only relevant when arguments_source is 'schema'. + if ( $this->schema_was_transformed && 'schema' === $this->arguments_source ) { + $adapter_meta['input_schema_transformed'] = true; + $adapter_meta['input_schema_wrapper'] = $this->schema_wrapper_property; + } + + // Preserve user-provided _meta from ability.meta.mcp._meta. + $prompt_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $prompt_meta ) { + $data['_meta'] = $prompt_meta; + } + + return array( + 'prompt_data' => $data, + 'adapter_meta' => $adapter_meta, + ); + } + + /** + * Get the MCP prompt data array. + * + * Per MCP 2025-11-25 specification, Prompt objects do NOT support annotations at the + * template level. Annotations are only supported on content blocks inside prompt messages + * (messages[].content.annotations). + * + * Arguments Resolution: + * 1. If `ability.meta.mcp.arguments` is defined and non-empty, use it directly (explicit override) + * 2. Otherwise, auto-convert from `ability.input_schema` + * + * This follows the `mcp.*` override pattern used elsewhere (mcp.uri, mcp.icons, mcp.annotations). + * + * @return array|\WP_Error Prompt data array, or WP_Error if explicit arguments are invalid. + * @since 0.5.0 + * + */ + private function get_data() { + $prompt_name = $this->resolve_prompt_name(); + if ( is_wp_error( $prompt_name ) ) { + return $prompt_name; + } + + $prompt_data = array( + 'name' => $prompt_name, + ); + + // Add optional title from ability label. + $label = trim( $this->ability->get_label() ); + if ( ! empty( $label ) ) { + $prompt_data['title'] = $label; + } + + // Add optional description. + $description = trim( $this->ability->get_description() ); + if ( ! empty( $description ) ) { + $prompt_data['description'] = $description; + } + + // Check for explicit mcp.arguments override first. + $explicit_arguments = $this->get_explicit_arguments(); + if ( is_array( $explicit_arguments ) && ! empty( $explicit_arguments ) ) { + $arguments = $this->convert_explicit_arguments( $explicit_arguments ); + if ( is_wp_error( $arguments ) ) { + return $arguments; + } + if ( ! empty( $arguments ) ) { + $prompt_data['arguments'] = $arguments; + $this->arguments_source = 'explicit'; + } + + return $prompt_data; + } + + // Fall back to auto-converting from input_schema. + $input_schema = $this->ability->get_input_schema(); + if ( ! empty( $input_schema ) ) { + // Use SchemaTransformer to handle flattened schemas (consistent with tool behavior). + $transform = SchemaTransformer::transform_to_object_schema( $input_schema ); + + // Track transformation state for _meta. + $this->schema_was_transformed = $transform['was_transformed']; + $this->schema_wrapper_property = $transform['wrapper_property']; + + $arguments = $this->convert_input_schema_to_arguments( $transform['schema'] ); + if ( ! empty( $arguments ) ) { + $prompt_data['arguments'] = $arguments; + $this->arguments_source = 'schema'; + } + } + + return $prompt_data; + } + + /** + * Get explicit arguments from ability meta.mcp.arguments. + * + * @return list>|null Explicit arguments array or null if not defined. + * @since 0.5.0 + * + */ + private function get_explicit_arguments(): ?array { + $meta = $this->ability->get_meta(); + if ( ! isset( $meta['mcp'] ) || ! is_array( $meta['mcp'] ) ) { + return null; + } + + $mcp = $meta['mcp']; + if ( ! isset( $mcp['arguments'] ) || ! is_array( $mcp['arguments'] ) ) { + return null; + } + + return array_values( $mcp['arguments'] ); + } + + /** + * Convert and validate explicit arguments from ability.meta.mcp.arguments. + * + * Per MCP 2025-11-25 specification, PromptArgument has: + * - name (string, required): Argument identifier + * - title (string, optional): Human-readable display name + * - description (string, optional): Human-readable description + * - required (boolean, optional): Whether the argument must be provided + * + * @param list> $explicit_arguments User-defined arguments array. + * + * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|\WP_Error PromptArgument DTOs or WP_Error. + * @since 0.5.0 + * + */ + private function convert_explicit_arguments( array $explicit_arguments ) { + $arguments = array(); + + foreach ( $explicit_arguments as $index => $arg ) { + if ( ! is_array( $arg ) ) { + return new WP_Error( + 'mcp_prompt_invalid_argument', + sprintf( + /* translators: 1: argument index, 2: ability name */ + __( 'Argument at index %1$d must be an array for ability "%2$s".', 'mcp-adapter' ), + $index, + $this->ability->get_name() + ) + ); + } + + // Validate required 'name' field. + if ( ! isset( $arg['name'] ) || ! is_string( $arg['name'] ) || '' === trim( $arg['name'] ) ) { + return new WP_Error( + 'mcp_prompt_argument_missing_name', + sprintf( + /* translators: 1: argument index, 2: ability name */ + __( 'Argument at index %1$d is missing required "name" field for ability "%2$s".', 'mcp-adapter' ), + $index, + $this->ability->get_name() + ) + ); + } + + $argument_data = array( + 'name' => trim( $arg['name'] ), + ); + + // Map optional 'title' field. + if ( isset( $arg['title'] ) && is_string( $arg['title'] ) && '' !== trim( $arg['title'] ) ) { + $argument_data['title'] = trim( $arg['title'] ); + } + + // Map optional 'description' field. + if ( isset( $arg['description'] ) && is_string( $arg['description'] ) && '' !== trim( $arg['description'] ) ) { + $argument_data['description'] = trim( $arg['description'] ); + } + + // Map optional 'required' field (only emit when true, per existing pattern). + if ( isset( $arg['required'] ) && true === $arg['required'] ) { + $argument_data['required'] = true; + } + + $arguments[] = PromptArgument::fromArray( $argument_data ); + } + + return $arguments; + } + + /** + * Convert JSON Schema input_schema to MCP prompt arguments format. + * + * Converts from WordPress Abilities JSON Schema format: + * { + * "type": "object", + * "properties": { + * "topic": {"type": "string", "title": "Topic", "description": "..."}, + * "tone": {"type": "string", "description": "..."} + * }, + * "required": ["topic"] + * } + * + * To MCP prompt arguments format: + * [ + * {"name": "topic", "title": "Topic", "description": "...", "required": true}, + * {"name": "tone", "description": "..."} + * ] + * + * Note: `required` is only emitted when true; optional arguments omit the field entirely. + * + * @param array $input_schema The JSON Schema from ability. + * + * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument> Argument DTO list. + * @since 0.5.0 + * + */ + private function convert_input_schema_to_arguments( array $input_schema ): array { + $arguments = array(); + + // Ensure we have properties to convert. + if ( empty( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) { + return $arguments; + } + + // Get the list of required properties. + $required_fields = array(); + if ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) ) { + $required_fields = $input_schema['required']; + } + + // Convert each property to an MCP argument. + foreach ( $input_schema['properties'] as $property_name => $property_schema ) { + if ( ! is_array( $property_schema ) ) { + continue; + } + + $is_required = in_array( $property_name, $required_fields, true ); + + $argument_data = array( + 'name' => $property_name, + ); + + // Map JSON Schema title to PromptArgument.title when present. + if ( ! empty( $property_schema['title'] ) && is_string( $property_schema['title'] ) ) { + $argument_data['title'] = $property_schema['title']; + } + + // Map JSON Schema description to PromptArgument.description when present. + if ( ! empty( $property_schema['description'] ) && is_string( $property_schema['description'] ) ) { + $argument_data['description'] = $property_schema['description']; + } + + // Only emit required when true; omit for optional arguments. + if ( $is_required ) { + $argument_data['required'] = true; + } + + $arguments[] = PromptArgument::fromArray( $argument_data ); + } + + return $arguments; + } + + /** + * Resolve the MCP prompt name from ability. + * + * Sanitizes the ability name to MCP-valid format, applies filter, and validates result. + * + * @since 0.5.0 + * + * @return string|\WP_Error Valid prompt name or error. + */ + private function resolve_prompt_name() { + // Sanitize ability name to MCP-valid format. + $sanitized_name = McpNameSanitizer::sanitize_name( $this->ability->get_name() ); + + if ( is_wp_error( $sanitized_name ) ) { + return $sanitized_name; + } + + /** + * Filters the MCP prompt name derived from an ability. + * + * @since 0.5.0 + * + * @param string $name The sanitized prompt name. + * @param \WP_Ability $ability The source ability instance. + */ + $filtered_name = apply_filters( 'mcp_adapter_prompt_name', $sanitized_name, $this->ability ); + + // Validate post-filter (in case filter broke it). + if ( ! is_string( $filtered_name ) || ! McpValidator::validate_name( $filtered_name ) ) { + return new WP_Error( + 'mcp_prompt_name_filter_invalid', + sprintf( + /* translators: %s: invalid prompt name returned by filter */ + __( 'Filter returned invalid MCP prompt name: %s', 'mcp-adapter' ), + is_string( $filtered_name ) ? $filtered_name : gettype( $filtered_name ) + ) + ); + } + + return $filtered_name; + } + + /** + * Build a clean Prompt DTO and adapter metadata for internal wiring. + * + * This method returns a protocol-only Prompt DTO and provides the adapter metadata + * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution + * wiring to protocol surfaces. + * + * @param \WP_Ability $ability The ability. + * + * @return array{prompt: \WP\McpSchema\Server\Prompts\DTO\Prompt, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + public static function build( \WP_Ability $ability ) { + $prompt = new self( $ability ); + $data = $prompt->build_prompt_data(); + + if ( is_wp_error( $data ) ) { + return $data; + } + + try { + $prompt_dto = PromptDto::fromArray( $data['prompt_data'] ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_prompt_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Prompt DTO for ability %1$s: %2$s', 'mcp-adapter' ), + $ability->get_name(), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpPromptValidator::validate_prompt_dto( $prompt_dto ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + return array( + 'prompt' => $prompt_dto, + 'adapter_meta' => $data['adapter_meta'], + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResource.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResource.php new file mode 100644 index 0000000000..8f858a1301 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResource.php @@ -0,0 +1,359 @@ + 'WordPress://local/readme', + * 'title' => 'README', + * 'description' => 'Example resource', + * 'handler' => fn() => 'Hello', + * 'permission' => fn() => true, + * ]); + * ``` + * + * 2. From WordPress Ability (ability-backed): + * ```php + * $resource = McpResource::fromAbility($ability); + * ``` + * + * McpResource wraps a protocol-only ResourceDto for MCP serialization. Internal + * adapter metadata and execution wiring live on this class and are never + * exposed to MCP clients. Use get_protocol_dto() for protocol responses. + * + * @since 0.5.0 + */ +final class McpResource implements McpComponentInterface { + + + // ========================================================================= + // Runtime Properties + // ========================================================================= + + /** + * Clean Resource DTO (protocol-only). + * + * @var \WP\McpSchema\Server\Resources\DTO\Resource + */ + private ResourceDto $mcp_resource_dto; + + /** + * Ability used for execution/permission checks (ability-backed resources). + * + * @var \WP_Ability|null + */ + private ?\WP_Ability $ability = null; + + /** + * Direct execution handler (callable-backed resources). + * + * @var callable|null + */ + private $handler = null; + + /** + * Direct permission callback (callable-backed resources). + * + * @var callable|null + */ + private $permission_callback = null; + + /** + * Internal adapter metadata (never exposed to clients). + * + * @var array + */ + private array $adapter_meta = array(); + + /** + * Observability context tags for logging/metrics. + * + * @var array + */ + private array $observability_context = array(); + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * Private constructor - use factory methods. + * + * @param \WP\McpSchema\Server\Resources\DTO\Resource $resource_dto The Resource DTO. + */ + private function __construct( ResourceDto $resource_dto ) { + $this->mcp_resource_dto = $resource_dto; + } + + // ========================================================================= + // Factory Methods + // ========================================================================= + + /** + * @param array $config The resource configuration array. + * + * @return self|\WP_Error + */ + public static function fromArray( array $config ) { + if ( empty( $config['uri'] ) ) { + return new WP_Error( 'mcp_resource_missing_uri', 'Resource configuration must include a "uri" field.' ); + } + + if ( ! isset( $config['handler'] ) || ! is_callable( $config['handler'] ) ) { + return new WP_Error( 'mcp_resource_missing_handler', 'Resource configuration must include a callable "handler" field.' ); + } + + $uri = trim( $config['uri'] ); + + if ( ! McpValidator::validate_resource_uri( $uri ) ) { + return new WP_Error( 'mcp_resource_invalid_uri', 'Resource "uri" must be a valid RFC 3986 URI with a scheme.' ); + } + + $name = isset( $config['name'] ) ? trim( $config['name'] ) : $uri; + if ( '' === $name ) { + return new WP_Error( 'mcp_resource_missing_name', 'Resource "name" cannot be empty.' ); + } + + $resource_data = array( + 'name' => $name, + 'uri' => $uri, + ); + + if ( isset( $config['title'] ) ) { + $resource_data['title'] = $config['title']; + } + + if ( isset( $config['description'] ) ) { + $resource_data['description'] = $config['description']; + } + + // Include mimeType when non-empty. The value itself is not checked. + if ( isset( $config['mimeType'] ) ) { + $mime_type = trim( $config['mimeType'] ); + if ( '' !== $mime_type ) { + $resource_data['mimeType'] = $mime_type; + } + } + + // Include size only when > 0. + if ( isset( $config['size'] ) && $config['size'] > 0 ) { + $resource_data['size'] = $config['size']; + } + + // Validate and include icons if set. + if ( isset( $config['icons'] ) && is_array( $config['icons'] ) && ! empty( $config['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $config['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $resource_data['icons'] = $icons_result['valid']; + } + } + + $resource_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $resource_meta ) { + $resource_data['_meta'] = $resource_meta; + } + + // Create the Resource DTO - wrap in try-catch since Annotations::fromArray() and ResourceDto::fromArray() can throw. + try { + // Process annotations inside try-catch since Annotations::fromArray() can throw. + if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) { + $resource_data['annotations'] = Annotations::fromArray( $config['annotations'] ); + } + + $resource = ResourceDto::fromArray( $resource_data ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_resource_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Resource DTO: %s', 'mcp-adapter' ), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpResourceValidator::validate_resource_dto( $resource ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + $instance = new self( $resource ); + $instance->handler = $config['handler']; + + if ( isset( $config['permission'] ) && is_callable( $config['permission'] ) ) { + $instance->permission_callback = $config['permission']; + } + + $instance->observability_context = array( + 'component_type' => 'resource', + 'resource_uri' => $uri, + 'source' => 'array', + ); + + return $instance; + } + + /** + * Create an ability-backed MCP resource. + * + * @param \WP_Ability $ability WordPress ability. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler. + * + * @return self|\WP_Error + */ + public static function fromAbility( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) { + $resource_data = RegisterAbilityAsMcpResource::build( $ability, $error_handler ); + if ( $resource_data instanceof WP_Error ) { + return $resource_data; + } + + $instance = new self( $resource_data['resource'] ); + $instance->adapter_meta = $resource_data['adapter_meta']; + $instance->ability = $ability; + + $instance->observability_context = array( + 'component_type' => 'resource', + 'resource_uri' => $resource_data['resource']->getUri(), + 'ability_name' => $ability->get_name(), + 'source' => 'ability', + ); + + return $instance; + } + + // ========================================================================= + // McpComponentInterface Implementation + // ========================================================================= + + /** + * Get the clean protocol DTO for MCP responses. + * + * @return \WP\McpSchema\Server\Resources\DTO\Resource + */ + public function get_protocol_dto(): ResourceDto { + return $this->mcp_resource_dto; + } + + /** + * Execute the resource read. + * + * @param mixed $arguments Read arguments (may be empty). + * + * @return mixed + */ + public function execute( $arguments ) { + // Ability-backed resources match existing behavior: no args passed to abilities. + if ( null !== $this->ability ) { + try { + return $this->ability->execute(); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + if ( null !== $this->handler ) { + try { + return call_user_func( $this->handler, $arguments ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + return new WP_Error( 'mcp_resource_no_handler', 'No resource execution strategy configured.' ); + } + + /** + * Check whether the current request has permission to read this resource. + * + * @param mixed $arguments Read arguments (may be empty). + * + * @return bool|\WP_Error + */ + public function check_permission( $arguments ) { + // Ability-backed resources match existing behavior: no args passed to abilities. + if ( null !== $this->ability ) { + try { + return $this->ability->check_permissions(); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + if ( null !== $this->permission_callback ) { + try { + $result = call_user_func( $this->permission_callback, $arguments ); + + return $result instanceof WP_Error ? $result : (bool) $result; + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + return new WP_Error( + 'mcp_permission_denied', + 'Access denied.', + array( 'failure_reason' => FailureReason::NO_PERMISSION_STRATEGY ) + ); + } + + /** + * Get internal adapter metadata for this resource. + * + * @return array + */ + public function get_adapter_meta(): array { + return $this->adapter_meta; + } + + /** + * Get observability context tags for logging/metrics. + * + * @return array + */ + public function get_observability_context(): array { + return $this->observability_context; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResourceValidator.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResourceValidator.php new file mode 100644 index 0000000000..e7190d2728 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/McpResourceValidator.php @@ -0,0 +1,191 @@ +getUri() ) ) { + $errors[] = __( 'Resource URI must be a valid URI string', 'mcp-adapter' ); + } + + // Validate icons if present. + $icons = $resource_dto->getIcons(); + if ( ! empty( $icons ) ) { + $icons_array = array_map( static fn( $icon ) => $icon->toArray(), $icons ); + $icons_result = McpValidator::validate_icons_array( $icons_array ); + $icons_errors = self::format_icon_validation_errors( $icons_result ); + $errors = array_merge( $errors, $icons_errors ); + } + + // Validate annotations if present. + $annotations = $resource_dto->getAnnotations(); + if ( $annotations ) { + $annotation_errors = McpValidator::get_annotation_validation_errors( $annotations->toArray() ); + $errors = array_merge( $errors, $annotation_errors ); + } + + if ( ! empty( $errors ) ) { + return new WP_Error( + 'mcp_resource_validation_failed', + sprintf( + /* translators: %s: list of validation errors */ + __( 'Resource validation failed: %s', 'mcp-adapter' ), + implode( '; ', $errors ) + ) + ); + } + + return true; + } + + /** + * Validate an McpResource instance against the MCP schema. + * + * @param \WP\MCP\Domain\Resources\McpResource $the_resource The resource instance to validate. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + public static function validate_resource_instance( McpResource $the_resource ) { + return self::validate_resource_dto( $the_resource->get_protocol_dto() ); + } + + /** + * Get validation errors for MCP resource contents. + * + * NOTE: This validates the `resource` object used by: + * - `resources/read` results (`TextResourceContents` / `BlobResourceContents`) + * - `content` blocks of type `resource` (`EmbeddedResource.resource`) + * + * It does NOT validate the `Resource` metadata object returned by `resources/list`. + * For `Resource` DTO validation (resources/list), use validate_resource_dto() instead. + * + * This validator focuses on the MCP-required fields and ignores unknown fields to remain + * forward-compatible with future schema versions. + * + * @param array $resource_data The resource contents object to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_validation_errors( array $resource_data ): array { + $errors = array(); + + // Validate the required URI field. + if ( empty( $resource_data['uri'] ) || ! is_string( $resource_data['uri'] ) ) { + $errors[] = __( 'Resource URI is required and must be a non-empty string', 'mcp-adapter' ); + } elseif ( ! McpValidator::validate_resource_uri( $resource_data['uri'] ) ) { + $errors[] = __( 'Resource URI must be a valid URI format', 'mcp-adapter' ); + } + + // Validate content: at least one of text/blob must be present and correctly typed. + // Use array_key_exists to allow empty strings as valid content. + $has_text_key = array_key_exists( 'text', $resource_data ); + $has_blob_key = array_key_exists( 'blob', $resource_data ); + + $has_text = $has_text_key && is_string( $resource_data['text'] ); + $has_blob = $has_blob_key && is_string( $resource_data['blob'] ); + + if ( ! $has_text && ! $has_blob ) { + $errors[] = __( 'Resource contents must include at least one of: text (string) or blob (base64 string)', 'mcp-adapter' ); + } + + if ( $has_text_key && ! is_string( $resource_data['text'] ) ) { + $errors[] = __( 'Resource text content must be a string when provided', 'mcp-adapter' ); + } + + if ( $has_blob_key && ! is_string( $resource_data['blob'] ) ) { + $errors[] = __( 'Resource blob content must be a string when provided', 'mcp-adapter' ); + } + + // Validate blob content if present and typed. + if ( $has_blob && ! McpValidator::validate_base64( $resource_data['blob'] ) ) { + $errors[] = __( 'Resource blob content must be valid base64-encoded data', 'mcp-adapter' ); + } + + // mimeType is optional. Only its type is checked. + if ( isset( $resource_data['mimeType'] ) && ! is_string( $resource_data['mimeType'] ) ) { + $errors[] = __( 'Resource mimeType must be a string if provided', 'mcp-adapter' ); + } + + return $errors; + } + + /** + * Format icon validation errors from the validation result. + * + * @param array{valid: array, errors: array} $icons_result The result from validate_icons_array. + * + * @return array Array of formatted error messages. + */ + private static function format_icon_validation_errors( array $icons_result ): array { + $errors = array(); + + if ( ! empty( $icons_result['errors'] ) ) { + foreach ( $icons_result['errors'] as $error_group ) { + foreach ( $error_group['errors'] as $error ) { + $errors[] = sprintf( + /* translators: 1: icon index, 2: error message */ + __( 'Icon at index %1$d: %2$s', 'mcp-adapter' ), + $error_group['index'], + $error + ); + } + } + } + + return $errors; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/RegisterAbilityAsMcpResource.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/RegisterAbilityAsMcpResource.php new file mode 100644 index 0000000000..5a01ac52e8 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Resources/RegisterAbilityAsMcpResource.php @@ -0,0 +1,478 @@ +ability = $ability; + $this->error_handler = $error_handler; + } + + /** + * Make a new instance of the class. + * + * @param \WP_Ability $ability The ability. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler for logging. + * + * @return \WP\McpSchema\Server\Resources\DTO\Resource|\WP_Error Returns Resource DTO or WP_Error if validation fails. + */ + public static function make( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) { + $resource = new self( $ability, $error_handler ); + + return $resource->get_resource(); + } + + /** + * Get the MCP resource instance. + * + * Resource schema validity is enforced by the php-mcp-schema DTO constructor. + * + * @return \WP\McpSchema\Server\Resources\DTO\Resource|\WP_Error Returns the Resource DTO or WP_Error if validation fails. + */ + private function get_resource() { + $data = $this->get_data(); + if ( is_wp_error( $data ) ) { + return $data; + } + + try { + return ResourceDto::fromArray( $data ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_resource_schema_invalid', + $e->getMessage() + ); + } + } + + /** + * Get the MCP resource data array. + * + * Builds metadata-only Resource data. Content (text/blob) is NOT included here; + * content is resolved at resources/read time by ResourcesHandler. + * + * @return array|\WP_Error Resource data array or WP_Error if validation fails. + */ + private function get_data() { + $built = $this->build_resource_data(); + if ( is_wp_error( $built ) ) { + return $built; + } + + return $built['resource_data']; + } + + /** + * Build Resource DTO data and adapter metadata. + * + * @return array{resource_data: array, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + private function build_resource_data() { + $uri = $this->get_uri(); + if ( is_wp_error( $uri ) ) { + return $uri; + } + + $ability_meta = $this->ability->get_meta(); + $mcp_meta = $ability_meta['mcp'] ?? array(); + + // Required fields. + $resource_data = array( + 'name' => $this->resolve_resource_name(), + 'uri' => $uri, + ); + + // Optional: title from ability label (human-readable display name). + $label = trim( $this->ability->get_label() ); + if ( '' !== $label ) { + $resource_data['title'] = $label; + } + + // Optional: description. + $description = trim( $this->ability->get_description() ); + if ( '' !== $description ) { + $resource_data['description'] = $description; + } + + // Optional: mimeType from ability meta. MCP treats it as an opaque string, so the + // value is emitted unaltered once surrounding whitespace is trimmed off; only a + // non-empty result is required. + $mime_type = $this->get_mcp_meta( 'mimeType', 'string' ); + if ( null !== $mime_type ) { + $mime_type = trim( $mime_type ); + if ( '' !== $mime_type ) { + $resource_data['mimeType'] = $mime_type; + } + } + + // Optional: size from ability meta (bytes count for UI display). + $size = $this->get_mcp_meta( 'size', 'int' ); + if ( null !== $size && $size > 0 ) { + $resource_data['size'] = $size; + } + + // Optional: annotations from ability meta (standardized location: mcp.annotations). + $annotations = $this->get_mcp_meta( 'annotations', 'array' ); + if ( null !== $annotations ) { + $mcp_annotations = McpAnnotationMapper::map( $annotations, 'resource' ); + if ( ! empty( $mcp_annotations ) ) { + // Validate annotation values per MCP specification. + $validation_errors = McpValidator::get_annotation_validation_errors( $mcp_annotations ); + if ( ! empty( $validation_errors ) ) { + // Log the issue but don't fail registration - drop invalid annotations. + $this->log_deprecation( + self::class . '::get_data', + sprintf( + /* translators: 1: ability name, 2: validation errors */ + __( 'Invalid annotations for resource ability "%1$s" will be dropped: %2$s', 'mcp-adapter' ), + $this->ability->get_name(), + implode( '; ', $validation_errors ) + ), + array( 'validation_errors' => $validation_errors ) + ); + } else { + $resource_data['annotations'] = $mcp_annotations; + } + } + } + + // Optional: icons from mcp.icons (already in correct location). + if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $resource_data['icons'] = $icons_result['valid']; + } + } + + // Build Resource `_meta`: + // - Preserve user-provided `_meta` from ability.meta.mcp._meta. + // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta. + $resource_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $resource_meta ) { + $resource_data['_meta'] = $resource_meta; + } + + $adapter_meta = array( + 'ability' => $this->ability->get_name(), + ); + + return array( + 'resource_data' => $resource_data, + 'adapter_meta' => $adapter_meta, + ); + } + + /** + * Get the resource URI with validation. + * + * @return string|\WP_Error URI string or WP_Error if not found or invalid. + */ + private function get_uri() { + $uri = $this->get_mcp_meta( 'uri', 'string' ); + + if ( null === $uri ) { + return new WP_Error( + 'resource_uri_not_found', + sprintf( + /* translators: %s: ability name */ + __( "Resource URI not found in ability meta for '%s'. URI must be provided at 'mcp.uri'.", 'mcp-adapter' ), + $this->ability->get_name() + ) + ); + } + + $uri = trim( $uri ); + + // Validate URI format (RFC 3986). + if ( ! McpValidator::validate_resource_uri( $uri ) ) { + return new WP_Error( + 'resource_uri_invalid', + sprintf( + /* translators: 1: ability name, 2: invalid URI */ + __( "Invalid resource URI '%2\$s' for ability '%1\$s'. URI must be RFC 3986 compliant with a scheme.", 'mcp-adapter' ), + $this->ability->get_name(), + $uri + ) + ); + } + + /** + * Filters the MCP resource URI derived from an ability. + * + * @since 0.5.0 + * + * @param string $uri The validated resource URI. + * @param \WP_Ability $ability The source ability instance. + */ + $filtered_uri = apply_filters( 'mcp_adapter_resource_uri', $uri, $this->ability ); + + // Validate post-filter. + if ( ! is_string( $filtered_uri ) || ! McpValidator::validate_resource_uri( $filtered_uri ) ) { + return new WP_Error( + 'mcp_resource_uri_filter_invalid', + sprintf( + /* translators: %s: invalid URI returned by filter */ + __( 'Filter returned invalid MCP resource URI: %s', 'mcp-adapter' ), + is_string( $filtered_uri ) ? $filtered_uri : gettype( $filtered_uri ) + ) + ); + } + + return $filtered_uri; + } + + /** + * Get a value from ability meta with standardized lookup. + * + * Looks in 'mcp' namespace first (preferred), then falls back to top-level (deprecated). + * Logs deprecation notice when using top-level location. + * + * @param string $key The key to look up. + * @param string $type Expected type: 'string', 'int', 'array'. + * @param mixed $default_value Default value if not found. + * + * @return mixed The value or default. + */ + private function get_mcp_meta( string $key, string $type = 'string', $default_value = null ) { + $ability_meta = $this->ability->get_meta(); + $mcp_meta = $ability_meta['mcp'] ?? array(); + + // Preferred: Check mcp.{key} first. + if ( isset( $mcp_meta[ $key ] ) ) { + $value = $mcp_meta[ $key ]; + if ( $this->validate_type( $value, $type ) ) { + return $value; + } + } + + // Deprecated fallback: Check top-level meta.{key}. + if ( isset( $ability_meta[ $key ] ) ) { + $value = $ability_meta[ $key ]; + if ( $this->validate_type( $value, $type ) ) { + // Log deprecation notice. + $this->log_deprecation( + __METHOD__, + sprintf( + /* translators: 1: deprecated meta key, 2: new meta key path */ + __( 'Ability meta key "%1$s" is deprecated. Use "mcp.%1$s" instead.', 'mcp-adapter' ), + $key + ), + array( 'deprecated_key' => $key ) + ); + + return $value; + } + } + + return $default_value; + } + + /** + * Validate a value against expected type. + * + * @param mixed $value The value to validate. + * @param string $type Expected type. + * + * @return bool True if valid. + */ + private function validate_type( $value, string $type ): bool { + switch ( $type ) { + case 'string': + return is_string( $value ) && '' !== trim( $value ); + case 'int': + return is_int( $value ) && $value >= 0; + case 'array': + // Array must be non-empty AND have at least one non-null, non-empty value. + // This prevents false positives when WordPress adds default empty annotations. + if ( ! is_array( $value ) || empty( $value ) ) { + return false; + } + // Check if any value in the array is actually meaningful (non-null, non-empty string). + foreach ( $value as $item ) { + if ( null !== $item && '' !== $item && array() !== $item ) { + return true; + } + } + + return false; + default: + return false; + } + } + + /** + * Log a deprecation notice via both WordPress _doing_it_wrong and McpErrorHandler. + * + * This ensures deprecation notices are visible both as HTTP headers (WordPress REST API) + * and in debug.log (McpErrorHandler). + * + * @param string $method The method name where deprecation occurred. + * @param string $message The deprecation message. + * @param array $context Additional context for error handler. + * + * @return void + */ + private function log_deprecation( string $method, string $message, array $context = array() ): void { + // WordPress standard deprecation notice (appears as X-WP-DoingItWrong header in REST API). + _doing_it_wrong( esc_html( $method ), esc_html( $message ), '0.5.0' ); + + // Also log via McpErrorHandler for debug.log visibility. + if ( ! $this->error_handler ) { + return; + } + + $this->error_handler->log( + $message, + array_merge( + array( 'ability' => $this->ability->get_name() ), + $context + ), + 'warning' + ); + } + + /** + * Resolve the MCP resource name from ability. + * + * Resource names have no charset restrictions (unlike Tool names). + * + * @return string The resolved resource name. + */ + private function resolve_resource_name(): string { + $name = $this->ability->get_name(); + + /** + * Filters the MCP resource name derived from an ability. + * + * Unlike tools, resource names have no charset restrictions. + * + * @since 0.5.0 + * + * @param string $name The resource name. + * @param \WP_Ability $ability The source ability instance. + */ + $filtered_name = apply_filters( 'mcp_adapter_resource_name', $name, $this->ability ); + + // Resource names have no charset restrictions, so just ensure it's a non-empty string. + if ( is_string( $filtered_name ) && '' !== trim( $filtered_name ) ) { + return $filtered_name; + } + + // Fall back to original name if filter returns invalid value. + return $name; + } + + /** + * Build a clean Resource DTO and adapter metadata for internal wiring. + * + * This method returns a protocol-only Resource DTO and provides the adapter metadata + * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution + * wiring to protocol surfaces. + * + * @param \WP_Ability $ability The ability. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler. + * + * @return array{resource: \WP\McpSchema\Server\Resources\DTO\Resource, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + public static function build( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) { + $resource = new self( $ability, $error_handler ); + $data = $resource->build_resource_data(); + + if ( is_wp_error( $data ) ) { + return $data; + } + + try { + $resource_dto = ResourceDto::fromArray( $data['resource_data'] ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_resource_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Resource DTO for ability %1$s: %2$s', 'mcp-adapter' ), + $ability->get_name(), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpResourceValidator::validate_resource_dto( $resource_dto ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + return array( + 'resource' => $resource_dto, + 'adapter_meta' => $data['adapter_meta'], + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpTool.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpTool.php new file mode 100644 index 0000000000..b69f7eb540 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpTool.php @@ -0,0 +1,417 @@ + 'uppercase-text', + * 'title' => 'Uppercase Text', + * 'description' => 'Converts text to uppercase', + * 'inputSchema' => ['type' => 'object', 'properties' => [...]], + * 'handler' => fn($args) => ['result' => strtoupper($args['text'])], + * 'permission' => fn() => true, + * 'annotations' => ['readOnlyHint' => true], + * ]); + * ``` + * + * 2. From WordPress Ability (ability-backed): + * ```php + * $tool = McpTool::fromAbility($ability); + * ``` + * + * McpTool wraps a protocol-only ToolDto for MCP serialization. Internal + * adapter metadata and execution wiring live on this class and are never + * exposed to MCP clients. Use get_protocol_dto() for protocol responses. + * + * @since 0.5.0 + */ +final class McpTool implements McpComponentInterface { + + + // ========================================================================= + // Runtime Properties + // ========================================================================= + + /** + * Clean Tool DTO (protocol-only). + * + * @var \WP\McpSchema\Server\Tools\DTO\Tool + */ + private ToolDto $tool; + + /** + * Ability used for execution/permission checks (ability-backed tools). + * + * @var \WP_Ability|null + */ + private ?\WP_Ability $ability = null; + + /** + * Direct execution handler (callable-backed tools). + * + * @var callable|null + */ + private $handler = null; + + /** + * Direct permission callback (callable-backed tools). + * + * @var callable|null + */ + private $permission_callback = null; + + /** + * Internal adapter metadata (never exposed to clients). + * + * @var array + */ + private array $adapter_meta = array(); + + /** + * Observability context tags for logging/metrics. + * + * @var array + */ + private array $observability_context = array(); + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * Private constructor - use factory methods. + * + * @param \WP\McpSchema\Server\Tools\DTO\Tool $tool The Tool DTO. + */ + private function __construct( ToolDto $tool ) { + $this->tool = $tool; + } + + // ========================================================================= + // Factory Methods + // ========================================================================= + + /** + * Create a tool definition from an array configuration. + * + * @param array $config The tool configuration array. + * + * @return self|\WP_Error + */ + public static function fromArray( array $config ) { + if ( empty( $config['name'] ) ) { + return new WP_Error( 'mcp_tool_missing_name', 'Tool configuration must include a "name" field.' ); + } + + if ( ! isset( $config['handler'] ) || ! is_callable( $config['handler'] ) ) { + return new WP_Error( 'mcp_tool_missing_handler', 'Tool configuration must include a callable "handler" field.' ); + } + + // Prepare input schema - ensure it's an object type for MCP compliance. + $input_schema = $config['inputSchema'] ?? array( 'type' => 'object' ); + if ( ! isset( $input_schema['type'] ) ) { + $input_schema['type'] = 'object'; + } + + // Build tool data array. + $tool_data = array( + 'name' => $config['name'], + 'inputSchema' => $input_schema, + ); + + // Optional fields. + if ( isset( $config['title'] ) ) { + $tool_data['title'] = $config['title']; + } + + if ( isset( $config['description'] ) ) { + $tool_data['description'] = $config['description']; + } + + if ( isset( $config['outputSchema'] ) && is_array( $config['outputSchema'] ) ) { + $tool_data['outputSchema'] = $config['outputSchema']; + } + + // Validate and prepare icons if set. + if ( isset( $config['icons'] ) && is_array( $config['icons'] ) && ! empty( $config['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $config['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $tool_data['icons'] = $icons_result['valid']; + } + } + + // Preserve user-provided _meta. + $tool_meta = McpValidator::normalize_meta( $config['meta'] ?? null ); + if ( null !== $tool_meta ) { + $tool_data['_meta'] = $tool_meta; + } + + // Create the Tool DTO - wrap in try-catch since ToolAnnotations::fromArray() and ToolDto::fromArray() can throw. + try { + // Process annotations inside try-catch since ToolAnnotations::fromArray() can throw. + if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) { + $tool_data['annotations'] = ToolAnnotations::fromArray( $config['annotations'] ); + } + + $tool = ToolDto::fromArray( $tool_data ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_tool_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Tool DTO: %s', 'mcp-adapter' ), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpToolValidator::validate_tool_dto( $tool ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + $instance = new self( $tool ); + $instance->handler = $config['handler']; + + if ( isset( $config['permission'] ) && is_callable( $config['permission'] ) ) { + $instance->permission_callback = $config['permission']; + } + + $instance->observability_context = array( + 'component_type' => 'tool', + 'tool_name' => $config['name'], + 'source' => 'array', + ); + + return $instance; + } + + /** + * Create an ability-backed MCP tool. + * + * @param \WP_Ability $ability WordPress ability. + * + * @return self|\WP_Error + */ + public static function fromAbility( \WP_Ability $ability ) { + $tool_data = RegisterAbilityAsMcpTool::build( $ability ); + if ( $tool_data instanceof WP_Error ) { + return $tool_data; + } + + $instance = new self( $tool_data['tool'] ); + $instance->adapter_meta = $tool_data['adapter_meta']; + $instance->ability = $ability; + + $instance->observability_context = array( + 'component_type' => 'tool', + 'tool_name' => $tool_data['tool']->getName(), + 'ability_name' => $ability->get_name(), + 'source' => 'ability', + ); + + return $instance; + } + + // ========================================================================= + // McpComponentInterface Implementation + // ========================================================================= + + /** + * Get the clean protocol DTO for MCP responses. + * + * @return \WP\McpSchema\Server\Tools\DTO\Tool + */ + public function get_protocol_dto(): ToolDto { + return $this->tool; + } + + /** + * Execute the tool. + * + * @param mixed $arguments Tool arguments. + * + * @return mixed + */ + public function execute( $arguments ) { + $args = $this->unwrap_input_if_needed( $arguments ); + + if ( null !== $this->ability ) { + $args = AbilityArgumentNormalizer::normalize( $this->ability, $args ); + + try { + $result = $this->ability->execute( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } elseif ( null !== $this->handler ) { + try { + $result = call_user_func( $this->handler, $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_execution_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } else { + return new WP_Error( 'mcp_tool_no_handler', 'No tool execution strategy configured.' ); + } + + if ( $result instanceof WP_Error ) { + return $result; + } + + $result = $this->wrap_output_if_needed( $result ); + + if ( ! is_array( $result ) ) { + $result = array( 'result' => $result ); + } + + return $result; + } + + /** + * Unwrap tool input arguments when the input schema was transformed (flattened → object wrapper). + * + * @param mixed $arguments Raw tool arguments. + * + * @return mixed + */ + private function unwrap_input_if_needed( $arguments ) { + $is_transformed = true === ( $this->adapter_meta['input_schema_transformed'] ?? false ); + + if ( ! $is_transformed ) { + return $arguments; + } + + $wrapper = $this->adapter_meta['input_schema_wrapper'] ?? 'input'; + $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'input'; + + return is_array( $arguments ) ? ( $arguments[ $wrapper ] ?? null ) : null; + } + + /** + * Wrap tool results when the output schema was transformed (flattened → object wrapper). + * + * @param mixed $result Raw result. + * + * @return mixed + */ + private function wrap_output_if_needed( $result ) { + $is_transformed = true === ( $this->adapter_meta['output_schema_transformed'] ?? false ); + + if ( ! $is_transformed ) { + return $result; + } + + $wrapper = $this->adapter_meta['output_schema_wrapper'] ?? 'result'; + $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'result'; + + return array( $wrapper => $result ); + } + + /** + * Check whether the current request has permission to execute this tool. + * + * @param mixed $arguments Tool arguments. + * + * @return bool|\WP_Error + */ + public function check_permission( $arguments ) { + $args = $this->unwrap_input_if_needed( $arguments ); + + // Ability-backed tools delegate to the ability's permission system. + if ( null !== $this->ability ) { + $args = AbilityArgumentNormalizer::normalize( $this->ability, $args ); + + try { + return $this->ability->check_permissions( $args ); + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + // Callable-backed tools use their required permission callback. + if ( null !== $this->permission_callback ) { + try { + $result = call_user_func( $this->permission_callback, $args ); + + return $result instanceof WP_Error ? $result : (bool) $result; + } catch ( \Throwable $throwable ) { + return new WP_Error( + 'mcp_permission_check_failed', + $throwable->getMessage(), + array( 'error_type' => get_class( $throwable ) ) + ); + } + } + + // Defensive fallback: should never reach here if factories are used correctly. + return new WP_Error( + 'mcp_permission_denied', + 'Access denied.', + array( + 'failure_reason' => FailureReason::NO_PERMISSION_STRATEGY, + 'tool_name' => $this->tool->getName(), + ) + ); + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + /** + * Get internal adapter metadata for this tool. + * + * @return array + */ + public function get_adapter_meta(): array { + return $this->adapter_meta; + } + + /** + * Get observability context tags for logging/metrics. + * + * @return array + */ + public function get_observability_context(): array { + return $this->observability_context; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpToolValidator.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpToolValidator.php new file mode 100644 index 0000000000..600c3d7478 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpToolValidator.php @@ -0,0 +1,488 @@ + + */ + private static array $valid_task_support_values = array( + 'forbidden', + 'optional', + 'required', + ); + + /** + * Validate the MCP tool data array against the MCP schema. + * + * @param array $tool_data The tool data to validate. + * @param string $context Optional context for error messages. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + public static function validate_tool_data( array $tool_data, string $context = '' ) { + $validation_errors = self::get_validation_errors( $tool_data ); + + if ( ! empty( $validation_errors ) ) { + $error_message = $context ? "[$context] " : ''; + $error_message .= sprintf( + /* translators: %s: comma-separated list of validation errors */ + __( 'Tool validation failed: %s', 'mcp-adapter' ), + implode( ', ', $validation_errors ) + ); + return new WP_Error( 'mcp_tool_validation_failed', esc_html( $error_message ) ); + } + + return true; + } + + /** + * Validate an McpTool instance against the MCP schema. + * + * @param \WP\MCP\Domain\Tools\McpTool $tool The tool instance to validate. + * @param string $context Optional context for error messages. + * + * @return bool|\WP_Error True if valid, WP_Error if validation fails. + */ + public static function validate_tool_instance( McpTool $tool, string $context = '' ) { + return self::validate_tool_data( $tool->get_protocol_dto()->toArray(), $context ); + } + + /** + * Validate a Tool DTO against the MCP schema. + * + * @param \WP\McpSchema\Server\Tools\DTO\Tool $tool The tool DTO to validate. + * + * @return bool|\WP_Error True if valid, WP_Error otherwise. + */ + public static function validate_tool_dto( ToolDto $tool ) { + $errors = array(); + + // Validate name (required, 1-128 chars, alphanumeric + _.-). + if ( ! McpValidator::validate_name( $tool->getName() ) ) { + $errors[] = __( 'Tool name must be 1-128 characters and contain only [A-Za-z0-9_.-]', 'mcp-adapter' ); + } + + // Validate icons if present. + $icons = $tool->getIcons(); + if ( ! empty( $icons ) ) { + // Convert DTO icons to arrays for validation. + $icons_array = array_map( static fn( $icon ) => $icon->toArray(), $icons ); + $icons_result = McpValidator::validate_icons_array( $icons_array ); + $icons_errors = self::format_icon_validation_errors( $icons_result ); + $errors = array_merge( $errors, $icons_errors ); + } + + // Validate annotations if present (tool-specific only). + $annotations = $tool->getAnnotations(); + if ( $annotations ) { + $annotations_array = $annotations->toArray(); + $annotation_errors = self::get_tool_annotation_validation_errors( $annotations_array ); + $errors = array_merge( $errors, $annotation_errors ); + } + + // Validate execution if present. + $execution = $tool->getExecution(); + if ( $execution ) { + $execution_array = $execution->toArray(); + $execution_errors = self::get_execution_validation_errors( $execution_array ); + $errors = array_merge( $errors, $execution_errors ); + } + + // Validate schemas (inputSchema and outputSchema). + $tool_array = $tool->toArray(); + + // Validate inputSchema (required field). + $input_schema_errors = self::get_schema_validation_errors( + $tool_array['inputSchema'] ?? null, + 'inputSchema' + ); + $errors = array_merge( $errors, $input_schema_errors ); + + // Validate outputSchema if present (optional field). + if ( isset( $tool_array['outputSchema'] ) ) { + $output_schema_errors = self::get_schema_validation_errors( + $tool_array['outputSchema'], + 'outputSchema' + ); + $errors = array_merge( $errors, $output_schema_errors ); + } + + if ( ! empty( $errors ) ) { + return new WP_Error( + 'mcp_tool_validation_failed', + sprintf( + /* translators: %s: list of validation errors */ + __( 'Tool validation failed: %s', 'mcp-adapter' ), + implode( '; ', $errors ) + ) + ); + } + + return true; + } + + /** + * Get validation error details for debugging purposes. + * This is the core validation method - all other validation methods use this. + * + * @param array $tool_data The tool data to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_validation_errors( array $tool_data ): array { + $errors = array(); + + // Check the required field: name. + if ( empty( $tool_data['name'] ) || ! is_string( $tool_data['name'] ) || ! McpValidator::validate_name( $tool_data['name'] ) ) { + $errors[] = __( 'Tool name is required and must only contain letters, numbers, hyphens (-), underscores (_), and dots (.), and be 128 characters or less', 'mcp-adapter' ); + } + + // Description is optional per MCP 2025-11-25 spec, but validate if present. + if ( isset( $tool_data['description'] ) && ! is_string( $tool_data['description'] ) ) { + $errors[] = __( 'Tool description must be a string if provided', 'mcp-adapter' ); + } + + // Validate inputSchema (required field). + $input_schema_errors = self::get_schema_validation_errors( $tool_data['inputSchema'] ?? null, 'inputSchema' ); + if ( ! empty( $input_schema_errors ) ) { + $errors = array_merge( $errors, $input_schema_errors ); + } + + // Check optional fields if present. + if ( isset( $tool_data['title'] ) && ! is_string( $tool_data['title'] ) ) { + $errors[] = __( 'Tool title must be a string if provided', 'mcp-adapter' ); + } + + // Validate outputSchema (optional field). + if ( isset( $tool_data['outputSchema'] ) ) { + $output_schema_errors = self::get_schema_validation_errors( $tool_data['outputSchema'], 'outputSchema' ); + if ( ! empty( $output_schema_errors ) ) { + $errors = array_merge( $errors, $output_schema_errors ); + } + } + + // Validate icons (optional field, new in 2025-11-25). + if ( isset( $tool_data['icons'] ) ) { + $icons_errors = self::get_icons_validation_errors( $tool_data['icons'] ); + if ( ! empty( $icons_errors ) ) { + $errors = array_merge( $errors, $icons_errors ); + } + } + + // Validate execution (optional field, new in 2025-11-25). + if ( isset( $tool_data['execution'] ) ) { + $execution_errors = self::get_execution_validation_errors( $tool_data['execution'] ); + if ( ! empty( $execution_errors ) ) { + $errors = array_merge( $errors, $execution_errors ); + } + } + + // Validate annotations structure if present (tool-specific annotations only). + if ( isset( $tool_data['annotations'] ) ) { + if ( ! is_array( $tool_data['annotations'] ) ) { + $errors[] = __( 'Tool annotations must be an array if provided', 'mcp-adapter' ); + } else { + // Validate tool-specific annotations (readOnlyHint, destructiveHint, etc.). + $tool_annotation_errors = self::get_tool_annotation_validation_errors( $tool_data['annotations'] ); + if ( ! empty( $tool_annotation_errors ) ) { + $errors = array_merge( $errors, $tool_annotation_errors ); + } + } + } + + // Validate _meta (optional field). + if ( isset( $tool_data['_meta'] ) && ! is_array( $tool_data['_meta'] ) ) { + $errors[] = __( 'Tool _meta must be an object/array if provided', 'mcp-adapter' ); + } + + return $errors; + } + + /** + * Get detailed validation errors for a schema object. + * + * @param array|mixed $schema The schema to validate. + * @param string $field_name The name of the field being validated (for error messages). + * + * @return array Array of validation errors, empty if valid. + */ + private static function get_schema_validation_errors( $schema, string $field_name ): array { + // Normalize stdClass to array for validation and reject scalars/null. + if ( $schema instanceof \stdClass ) { + $schema = (array) $schema; + } + + // Schema must be an array/object - early return for performance. + if ( ! is_array( $schema ) ) { + return array( + sprintf( + /* translators: %s: field name (inputSchema or outputSchema) */ + __( 'Tool %s must be a valid JSON schema object', 'mcp-adapter' ), + $field_name + ), + ); + } + + $errors = array(); + + // MCP Tool inputSchema and outputSchema are currently restricted to a root type of "object". + if ( ! isset( $schema['type'] ) ) { + $errors[] = sprintf( + /* translators: %s: field name */ + __( 'Tool %s must specify a root type of \'object\'', 'mcp-adapter' ), + $field_name + ); + } elseif ( ! is_string( $schema['type'] ) || 'object' !== $schema['type'] ) { + $errors[] = sprintf( + /* translators: %s: field name */ + __( 'Tool %s root type must be \'object\'', 'mcp-adapter' ), + $field_name + ); + } + + // Normalize stdClass properties (e.g. an empty `{}` emitted by the schema DTO for + // parameter-less tools) to an array so the structural checks below treat it as a valid object. + if ( isset( $schema['properties'] ) && $schema['properties'] instanceof \stdClass ) { + $schema['properties'] = (array) $schema['properties']; + } + + // If properties exist, they must be an array/object. + if ( isset( $schema['properties'] ) && ! is_array( $schema['properties'] ) ) { + $errors[] = sprintf( + /* translators: %s: field name */ + __( 'Tool %s properties must be an object/array', 'mcp-adapter' ), + $field_name + ); + } + + // If required exists, it must be an array. + if ( isset( $schema['required'] ) && ! is_array( $schema['required'] ) ) { + $errors[] = sprintf( + /* translators: %s: field name */ + __( 'Tool %s required field must be an array', 'mcp-adapter' ), + $field_name + ); + } + + // If properties are provided, validate their basic structure. + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $property_name => $property ) { + // Normalize stdClass to array for property validation. + if ( $property instanceof \stdClass ) { + $property = (array) $property; + } + + if ( ! is_array( $property ) ) { + $errors[] = sprintf( + /* translators: %1$s: field name, %2$s: property name */ + __( 'Tool %1$s property \'%2$s\' must be an object', 'mcp-adapter' ), + $field_name, + $property_name + ); + continue; + } + + // Each property should have a type (though not strictly required by JSON Schema). + if ( ! isset( $property['type'] ) || is_string( $property['type'] ) || is_array( $property['type'] ) ) { + continue; + } + + // If the type is neither string nor array, it's invalid. + $errors[] = sprintf( + /* translators: %1$s: field name, %2$s: property name */ + __( 'Tool %1$s property \'%2$s\' type must be a string or array of strings (union type)', 'mcp-adapter' ), + $field_name, + $property_name + ); + } + } + + // If the required array is provided, validate its structure. + if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) { + foreach ( $schema['required'] as $required_field ) { + if ( ! is_string( $required_field ) ) { + $errors[] = sprintf( + /* translators: %s: field name */ + __( 'Tool %s required field names must be strings', 'mcp-adapter' ), + $field_name + ); + continue; + } + + // Check that required fields exist in properties (if properties are defined). + if ( ! isset( $schema['properties'] ) || isset( $schema['properties'][ $required_field ] ) ) { + continue; + } + + $errors[] = sprintf( + /* translators: %1$s: field name, %2$s: required field */ + __( 'Tool %1$s required field \'%2$s\' does not exist in properties', 'mcp-adapter' ), + $field_name, + $required_field + ); + } + } + + return $errors; + } + + /** + * Get validation errors for tool icons array. + * + * @param mixed $icons The icons data to validate. + * + * @return array Array of validation errors, empty if valid. + */ + private static function get_icons_validation_errors( $icons ): array { + if ( ! is_array( $icons ) ) { + return array( __( 'Tool icons must be an array if provided', 'mcp-adapter' ) ); + } + + $icons_result = McpValidator::validate_icons_array( $icons, false ); + + return self::format_icon_validation_errors( $icons_result ); + } + + /** + * Format icon validation errors from the validation result. + * + * @param array{valid: array, errors: array} $icons_result The result from validate_icons_array. + * + * @return array Array of formatted error messages. + */ + private static function format_icon_validation_errors( array $icons_result ): array { + $errors = array(); + + if ( ! empty( $icons_result['errors'] ) ) { + foreach ( $icons_result['errors'] as $error_group ) { + foreach ( $error_group['errors'] as $error ) { + $errors[] = sprintf( + /* translators: 1: icon index, 2: error message */ + __( 'Icon at index %1$d: %2$s', 'mcp-adapter' ), + $error_group['index'], + $error + ); + } + } + } + + return $errors; + } + + /** + * Get validation errors for tool execution properties. + * + * Validates execution-related properties per MCP 2025-11-25 specification: + * - taskSupport must be one of: "forbidden", "optional", "required" + * + * @param mixed $execution The execution data to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_execution_validation_errors( $execution ): array { + if ( ! is_array( $execution ) ) { + return array( __( 'Tool execution must be an object/array if provided', 'mcp-adapter' ) ); + } + + $errors = array(); + + // Validate taskSupport if present. + if ( isset( $execution['taskSupport'] ) ) { + if ( ! is_string( $execution['taskSupport'] ) ) { + $errors[] = __( 'Tool execution taskSupport must be a string', 'mcp-adapter' ); + } elseif ( ! in_array( $execution['taskSupport'], self::$valid_task_support_values, true ) ) { + $errors[] = sprintf( + /* translators: %s: comma-separated list of valid values */ + __( 'Tool execution taskSupport must be one of: %s', 'mcp-adapter' ), + implode( ', ', self::$valid_task_support_values ) + ); + } + } + + return $errors; + } + + /** + * Get validation errors for tool-specific MCP annotations. + * + * Validates tool annotation fields per MCP 2025-11-25 specification: + * - readOnlyHint, destructiveHint, idempotentHint, openWorldHint must be booleans + * - title must be a non-empty string + * + * Note: Tools use ToolAnnotations which is different from the shared Annotations class. + * ToolAnnotations does NOT include audience, lastModified, or priority fields. + * + * @param array $annotations The annotations to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_tool_annotation_validation_errors( array $annotations ): array { + $errors = array(); + + foreach ( $annotations as $field => $value ) { + switch ( $field ) { + case 'readOnlyHint': + case 'destructiveHint': + case 'idempotentHint': + case 'openWorldHint': + if ( ! is_bool( $value ) ) { + $errors[] = sprintf( + /* translators: %s: annotation field name */ + __( 'Tool annotation field %s must be a boolean', 'mcp-adapter' ), + $field + ); + } + break; + + case 'title': + if ( ! is_string( $value ) ) { + $errors[] = sprintf( + /* translators: %s: annotation field name */ + __( 'Tool annotation field %s must be a string', 'mcp-adapter' ), + $field + ); + break; + } + if ( empty( trim( $value ) ) ) { + $errors[] = sprintf( + /* translators: %s: annotation field name */ + __( 'Tool annotation field %s must be a non-empty string', 'mcp-adapter' ), + $field + ); + } + break; + + default: + // Unknown fields are ignored to allow forward compatibility. + break; + } + } + + return $errors; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/RegisterAbilityAsMcpTool.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/RegisterAbilityAsMcpTool.php new file mode 100644 index 0000000000..124eac8f07 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Tools/RegisterAbilityAsMcpTool.php @@ -0,0 +1,238 @@ +ability = $ability; + } + + /** + * Build a clean Tool DTO and adapter metadata for internal wiring. + * + * This method returns a protocol-only Tool DTO and provides the adapter metadata + * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution + * wiring to protocol surfaces. + * + * @param \WP_Ability $ability The ability. + * + * @return array{tool: \WP\McpSchema\Server\Tools\DTO\Tool, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + public static function build( \WP_Ability $ability ) { + $tool = new self( $ability ); + $data = $tool->build_tool_data(); + + if ( is_wp_error( $data ) ) { + return $data; + } + + try { + $tool_dto = ToolDto::fromArray( $data['tool_data'] ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'mcp_tool_dto_creation_failed', + sprintf( + /* translators: %s: error message */ + __( 'Failed to create Tool DTO for ability %1$s: %2$s', 'mcp-adapter' ), + $ability->get_name(), + $e->getMessage() + ), + array( 'exception' => $e ) + ); + } + + // Optional deep validation if enabled. + $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); + if ( $mcp_validation_enabled ) { + $validation_result = McpToolValidator::validate_tool_dto( $tool_dto ); + if ( is_wp_error( $validation_result ) ) { + return $validation_result; + } + } + + return array( + 'tool' => $tool_dto, + 'adapter_meta' => $data['adapter_meta'], + ); + } + + /** + * Build Tool DTO data and adapter metadata. + * + * @return array{tool_data: array, adapter_meta: array}|\WP_Error + * @since 0.5.0 + * + */ + private function build_tool_data() { + // Resolve tool name first (can fail). + $tool_name = $this->resolve_tool_name(); + if ( is_wp_error( $tool_name ) ) { + return $tool_name; + } + + // Transform input schema to MCP-compatible object format. + $input_transform = SchemaTransformer::transform_to_object_schema( + $this->ability->get_input_schema() + ); + + $tool_data = array( + 'name' => $tool_name, + 'description' => trim( $this->ability->get_description() ), + 'inputSchema' => $input_transform['schema'], + ); + + // Add optional title from ability label. + $label = $this->ability->get_label(); + $label = trim( $label ); + if ( ! empty( $label ) ) { + $tool_data['title'] = $label; + } + + // Add optional output schema, transformed to object format if needed. + $output_schema = $this->ability->get_output_schema(); + $output_transform = null; + if ( ! empty( $output_schema ) ) { + $output_transform = SchemaTransformer::transform_to_object_schema( + $output_schema, + 'result' + ); + $tool_data['outputSchema'] = $output_transform['schema']; + } + + // Map annotations from ability meta to MCP format using unified mapper. + $ability_meta = $this->ability->get_meta(); + if ( ! empty( $ability_meta['annotations'] ) && is_array( $ability_meta['annotations'] ) ) { + $mcp_annotations = McpAnnotationMapper::map( $ability_meta['annotations'], 'tool' ); + if ( ! empty( $mcp_annotations ) ) { + $tool_data['annotations'] = $mcp_annotations; + } + } + + // Set annotations.title from label if annotations exist but don't have a title. + if ( ! empty( $label ) && isset( $tool_data['annotations'] ) && ! isset( $tool_data['annotations']['title'] ) ) { + $tool_data['annotations']['title'] = $label; + } + + // Store transformation metadata as internal metadata (stripped before responding to clients). + // Only record keys when semantically meaningful to keep metadata minimal and accurate. + $adapter_meta = array( + 'ability' => $this->ability->get_name(), + ); + + // Only record input transformation metadata when a wrapper was actually applied. + if ( ! empty( $input_transform['was_transformed'] ) ) { + $adapter_meta['input_schema_transformed'] = true; + $adapter_meta['input_schema_wrapper'] = $input_transform['wrapper_property']; + } + + // Only record output transformation metadata when outputSchema exists. + // Record wrapper only when transformation actually occurred. + if ( null !== $output_transform && ! empty( $output_transform['was_transformed'] ) ) { + $adapter_meta['output_schema_transformed'] = true; + $adapter_meta['output_schema_wrapper'] = $output_transform['wrapper_property']; + } + + // Map icons from ability.meta.mcp.icons if present. + $mcp_meta = $ability_meta['mcp'] ?? array(); + if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) { + $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] ); + if ( ! empty( $icons_result['valid'] ) ) { + $tool_data['icons'] = $icons_result['valid']; + } + } + + // Build Tool `_meta`: + // - Preserve user-provided `_meta` from ability.meta.mcp._meta. + // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta. + $tool_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null ); + if ( null !== $tool_meta ) { + $tool_data['_meta'] = $tool_meta; + } + + return array( + 'tool_data' => $tool_data, + 'adapter_meta' => $adapter_meta, + ); + } + + /** + * Resolve the MCP tool name from ability. + * + * Sanitizes the ability name to MCP-valid format, applies filter, and validates result. + * + * @return string|\WP_Error Valid tool name or error. + * @since 0.5.0 + * + */ + private function resolve_tool_name() { + // Sanitize ability name to MCP-valid format. + $sanitized_name = McpNameSanitizer::sanitize_name( $this->ability->get_name() ); + + if ( is_wp_error( $sanitized_name ) ) { + return $sanitized_name; + } + + /** + * Filters the MCP tool name derived from an ability. + * + * @since 0.5.0 + * + * @param string $name The sanitized tool name. + * @param \WP_Ability $ability The source ability instance. + */ + $filtered_name = apply_filters( 'mcp_adapter_tool_name', $sanitized_name, $this->ability ); + + // Validate post-filter (in case filter broke it). + if ( ! is_string( $filtered_name ) || ! McpValidator::validate_name( $filtered_name ) ) { + return new WP_Error( + 'mcp_tool_name_filter_invalid', + sprintf( + /* translators: %s: invalid tool name returned by filter */ + __( 'Filter returned invalid MCP tool name: %s', 'mcp-adapter' ), + is_string( $filtered_name ) ? $filtered_name : gettype( $filtered_name ) + ) + ); + } + + return $filtered_name; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/AbilityArgumentNormalizer.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/AbilityArgumentNormalizer.php new file mode 100644 index 0000000000..1430af19d8 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/AbilityArgumentNormalizer.php @@ -0,0 +1,107 @@ +" (object, array, and so on). Two + * exceptions keep null: a schema whose type explicitly permits null (an + * explicit null value is passed through), and a schema with a top-level + * default, where null lets the Abilities API apply that default for both a + * null and an empty {} input. In both, null is the author's declared intent. + * + * @since 0.5.0 + */ +class AbilityArgumentNormalizer { + + /** + * Normalize parameters for an ability based on its input schema. + * + * No input schema: empty arrays are converted to null, so abilities that + * take no parameters see null. + * Has input schema: null or an empty array normalizes to an empty array so a + * zero-argument call passes schema validation instead of failing as "not of + * type " (object, array, and so on). Two exceptions return null: + * a top-level default (both null and {} return null so the Abilities API + * applies the default) and a type that explicitly permits null (an explicit + * null is passed through). + * + * @param \WP_Ability $ability The ability to normalize parameters for. + * @param mixed $parameters The parameters to normalize. + * + * @return mixed Normalized parameters (null when no schema and params are empty; empty array when a schema is present and params are empty or null, unless the schema declares a top-level default or its type permits null, in which case null is returned). + * @since 0.5.0 + * @since 0.6.0 Empty or null parameters for schema-defining abilities normalize to an empty array, except when the schema declares a top-level default (honored for both null and empty {} input) or its type explicitly permits null. + */ + public static function normalize( \WP_Ability $ability, $parameters ) { + $input_schema = $ability->get_input_schema(); + + // No schema: an empty {} means "no arguments" -> null. + if ( empty( $input_schema ) ) { + return is_array( $parameters ) && empty( $parameters ) ? null : $parameters; + } + + // Has schema, missing/empty argument set (null or {}). + if ( null === $parameters || array() === $parameters ) { + // A top-level default is the author's declared "no input" value. + // Return null for BOTH null and {} so WP_Ability::normalize_input() + // applies the default -- it fills the default only when input is null. + // An MCP client sends {} to mean "no arguments", so {} must honor the + // default too, not just an omitted parameter. + if ( array_key_exists( 'default', $input_schema ) ) { + return null; + } + + // An explicit null from the client is kept only when the schema's + // type permits null; {} stays [] as an empty object. + if ( null === $parameters && self::schema_permits_null( $input_schema ) ) { + return null; + } + + // Otherwise use [], which satisfies an empty object or array schema + // so a zero-argument call validates. null never validates on its own. + return array(); + } + + return $parameters; + } + + /** + * Whether the schema's top-level type explicitly permits null. + * + * Only an explicit top-level type is honored (JSON Schema "null", or a type + * array containing "null"). Composition keywords that also permit null + * (anyOf/oneOf/enum/const) are not inspected; such a schema falls through to + * [], which still validates when object or array is among the allowed forms. + * A schema with no type is treated as not permitting null, so a zero-argument + * call still normalizes to [] for callbacks that expect an array. + * + * @param array $input_schema The ability input schema. + * + * @return bool True when null is a valid top-level value for the schema. + * @since 0.6.0 + */ + private static function schema_permits_null( array $input_schema ): bool { + $type = $input_schema['type'] ?? null; + + if ( is_array( $type ) ) { + return in_array( 'null', $type, true ); + } + + return 'null' === $type; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/ContentBlockHelper.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/ContentBlockHelper.php new file mode 100644 index 0000000000..57acd3388d --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/ContentBlockHelper.php @@ -0,0 +1,254 @@ + ImageContent::TYPE, + 'data' => $data, + 'mimeType' => $mime_type, + 'annotations' => $annotations, + '_meta' => McpValidator::normalize_meta( $_meta ), + ) + ); + } + + /** + * Creates an AudioContent DTO. + * + * @param string $data Base64-encoded audio data. + * @param string $mime_type The MIME type of the audio (e.g., 'audio/mp3'). + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * + * @return \WP\McpSchema\Common\Content\DTO\AudioContent The created AudioContent DTO. + */ + public static function audio( string $data, string $mime_type, ?Annotations $annotations = null, ?array $_meta = null ): AudioContent { + return AudioContent::fromArray( + array( + 'type' => AudioContent::TYPE, + 'data' => $data, + 'mimeType' => $mime_type, + 'annotations' => $annotations, + '_meta' => McpValidator::normalize_meta( $_meta ), + ) + ); + } + + /** + * Creates an EmbeddedResource DTO with TextResourceContents. + * + * Use this for embedding text-based resources (files, documents, etc.) in content. + * + * The DTO tree has two levels that each carry their own `_meta`: the content + * block wrapper and the resource contents nested inside it. `$_meta` sets the + * wrapper's; `$resource_meta` sets the contents'. They are distinct fields in + * the spec and are not interchangeable. + * + * @since 0.6.0 Added the optional $resource_meta parameter. + * + * @param string $uri The URI of the resource. + * @param string $text The text content of the resource. + * @param string|null $mime_type Optional MIME type of the resource. + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * @param array|null $resource_meta Optional metadata for the nested resource contents. + * + * @return \WP\McpSchema\Common\Protocol\DTO\EmbeddedResource The created EmbeddedResource DTO. + */ + public static function embedded_text_resource( + string $uri, + string $text, + ?string $mime_type = null, + ?Annotations $annotations = null, + ?array $_meta = null, + ?array $resource_meta = null + ): EmbeddedResource { + $resource = TextResourceContents::fromArray( + array( + 'uri' => $uri, + 'text' => $text, + 'mimeType' => $mime_type, + '_meta' => McpValidator::normalize_meta( $resource_meta ), + ) + ); + + return EmbeddedResource::fromArray( + array( + 'type' => EmbeddedResource::TYPE, + 'resource' => $resource, + 'annotations' => $annotations, + '_meta' => McpValidator::normalize_meta( $_meta ), + ) + ); + } + + /** + * Creates an EmbeddedResource DTO with BlobResourceContents. + * + * Use this for embedding binary resources (images, PDFs, etc.) in content. + * + * The DTO tree has two levels that each carry their own `_meta`: the content + * block wrapper and the resource contents nested inside it. `$_meta` sets the + * wrapper's; `$resource_meta` sets the contents'. They are distinct fields in + * the spec and are not interchangeable. + * + * @since 0.6.0 Added the optional $resource_meta parameter. + * + * @param string $uri The URI of the resource. + * @param string $blob Base64-encoded binary data. + * @param string|null $mime_type Optional MIME type of the resource. + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * @param array|null $resource_meta Optional metadata for the nested resource contents. + * + * @return \WP\McpSchema\Common\Protocol\DTO\EmbeddedResource The created EmbeddedResource DTO. + */ + public static function embedded_blob_resource( + string $uri, + string $blob, + ?string $mime_type = null, + ?Annotations $annotations = null, + ?array $_meta = null, + ?array $resource_meta = null + ): EmbeddedResource { + $resource = BlobResourceContents::fromArray( + array( + 'uri' => $uri, + 'blob' => $blob, + 'mimeType' => $mime_type, + '_meta' => McpValidator::normalize_meta( $resource_meta ), + ) + ); + + return EmbeddedResource::fromArray( + array( + 'type' => EmbeddedResource::TYPE, + 'resource' => $resource, + 'annotations' => $annotations, + '_meta' => McpValidator::normalize_meta( $_meta ), + ) + ); + } + + /** + * Creates a TextContent DTO for error messages. + * + * Convenience method for creating text content specifically for error responses. + * This is semantically equivalent to text() but makes the intent clearer in code. + * + * @param string $message The error message. + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * + * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. + */ + public static function error_text( string $message, ?Annotations $annotations = null, ?array $_meta = null ): TextContent { + return self::text( $message, $annotations, $_meta ); + } + + /** + * Creates a TextContent DTO. + * + * @param string $text The text content. + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * + * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. + */ + public static function text( string $text, ?Annotations $annotations = null, ?array $_meta = null ): TextContent { + return TextContent::fromArray( + array( + 'type' => TextContent::TYPE, + 'text' => $text, + 'annotations' => $annotations, + '_meta' => McpValidator::normalize_meta( $_meta ), + ) + ); + } + + /** + * Creates a TextContent DTO with JSON-encoded data. + * + * Convenience method for creating text content from structured data. + * The data is encoded as JSON and wrapped in a TextContent DTO. + * + * @param mixed $data The data to JSON-encode. + * @param int $flags JSON encoding flags (default: 0). + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations Optional annotations for the client. + * @param array|null $_meta Optional metadata for the content block. + * + * @return \WP\McpSchema\Common\Content\DTO\TextContent The created TextContent DTO. + */ + public static function json_text( $data, int $flags = 0, ?Annotations $annotations = null, ?array $_meta = null ): TextContent { + $json = wp_json_encode( $data, $flags ); + if ( false === $json ) { + $json = '{}'; + } + + return self::text( $json, $annotations, $_meta ); + } + + /** + * Converts an array of ContentBlockInterface DTOs to their array representations. + * + * Use this at the serialization boundary when preparing content blocks for JSON output. + * + * @param \WP\McpSchema\Common\Protocol\Union\ContentBlockInterface[] $blocks Array of content block DTOs. + * + * @return array[] Array of content block arrays. + */ + public static function to_array_list( array $blocks ): array { + return array_map( + static function ( ContentBlockInterface $block ): array { + return $block->toArray(); + }, + $blocks + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpAnnotationMapper.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpAnnotationMapper.php new file mode 100644 index 0000000000..81ba440eca --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpAnnotationMapper.php @@ -0,0 +1,232 @@ +, ability_property: string|null}> + */ + private static array $mcp_annotations = array( + // Shared annotations - Resources only (NOT Tools or Prompt templates per MCP spec). + // ToolAnnotations is a separate type that does not include these fields. + // Prompt templates do not support annotations; only content blocks inside messages do. + 'audience' => array( + 'type' => 'array', + 'features' => array( 'resource' ), + 'ability_property' => null, + ), + 'lastModified' => array( + 'type' => 'string', + 'features' => array( 'resource' ), + 'ability_property' => null, + ), + 'priority' => array( + 'type' => 'number', + 'features' => array( 'resource' ), + 'ability_property' => null, + ), + // Tool-specific annotations (ToolAnnotations type per MCP 2025-11-25 spec). + 'readOnlyHint' => array( + 'type' => 'boolean', + 'features' => array( 'tool' ), + 'ability_property' => 'readonly', + ), + 'destructiveHint' => array( + 'type' => 'boolean', + 'features' => array( 'tool' ), + 'ability_property' => 'destructive', + ), + 'idempotentHint' => array( + 'type' => 'boolean', + 'features' => array( 'tool' ), + 'ability_property' => 'idempotent', + ), + 'openWorldHint' => array( + 'type' => 'boolean', + 'features' => array( 'tool' ), + 'ability_property' => null, + ), + 'title' => array( + 'type' => 'string', + 'features' => array( 'tool' ), + 'ability_property' => null, + ), + ); + + /** + * Map WordPress ability annotation property names to MCP field names. + * + * Maps WordPress-format field names to MCP equivalents (e.g., readonly → readOnlyHint). + * Only includes annotations applicable to the specified feature type. + * Null values are excluded from the result. + * + * @param array $ability_annotations WordPress ability annotations. + * @param string $feature_type The MCP feature type ('tool', 'resource', or 'prompt'). + * + * @return array Mapped annotations for the specified feature type. + */ + public static function map( array $ability_annotations, string $feature_type ): array { + $result = array(); + + foreach ( self::$mcp_annotations as $mcp_field => $config ) { + if ( ! in_array( $feature_type, $config['features'], true ) ) { + continue; + } + + $value = self::resolve_annotation_value( + $ability_annotations, + $mcp_field, + $config['ability_property'] + ); + + if ( null === $value ) { + continue; + } + + $normalized = self::normalize_annotation_value( $config['type'], $value ); + if ( null === $normalized ) { + continue; + } + + $result[ $mcp_field ] = $normalized; + } + + return $result; + } + + /** + * Resolve the annotation value, preferring WordPress-format overrides when available. + * + * @param array $annotations Raw annotations from the ability. + * @param string $mcp_field The MCP field name. + * @param string|null $ability_property Optional WordPress-format field name, or null if mapping 1:1. + * + * @return mixed The annotation value, or null if not found. + */ + private static function resolve_annotation_value( array $annotations, string $mcp_field, ?string $ability_property ) { + // WordPress-format overrides take precedence when present. + if ( null !== $ability_property && array_key_exists( $ability_property, $annotations ) && ! is_null( $annotations[ $ability_property ] ) ) { + return $annotations[ $ability_property ]; + } + + if ( array_key_exists( $mcp_field, $annotations ) && ! is_null( $annotations[ $mcp_field ] ) ) { + return $annotations[ $mcp_field ]; + } + + return null; + } + + /** + * Normalize annotation values to the types expected by MCP. + * + * @param string $field_type Expected MCP type (boolean, string, array, number). + * @param mixed $value Raw annotation value. + * + * @return mixed|null Normalized value or null if invalid. + */ + private static function normalize_annotation_value( string $field_type, $value ) { + switch ( $field_type ) { + case 'boolean': + return self::normalize_boolean( $value ); + + case 'string': + if ( ! is_scalar( $value ) ) { + return null; + } + $trimmed = trim( (string) $value ); + + return '' === $trimmed ? null : $trimmed; + + case 'array': + return is_array( $value ) && ! empty( $value ) ? $value : null; + + case 'number': + return is_numeric( $value ) ? (float) $value : null; + + default: + return $value; + } + } + + /** + * Normalize a value to a strict boolean. + * + * Accepts only well-defined boolean representations to avoid ambiguous conversions. + * PHP's default (bool) cast incorrectly converts 'false' string to true. + * + * Accepted values: + * - true, false (PHP booleans) + * - 1, 0 (integers) + * - '1', '0', 'true', 'false' (case-insensitive strings) + * + * @param mixed $value The value to normalize. + * + * @return bool|null The normalized boolean, or null if value cannot be safely converted. + */ + private static function normalize_boolean( $value ): ?bool { + // Already a boolean - return as-is. + if ( is_bool( $value ) ) { + return $value; + } + + // Integer 1 or 0. + if ( is_int( $value ) ) { + if ( 1 === $value ) { + return true; + } + if ( 0 === $value ) { + return false; + } + + // Other integers are invalid (e.g., 2, -1). + return null; + } + + // String representations (case-insensitive). + if ( is_string( $value ) ) { + $lower = strtolower( trim( $value ) ); + if ( 'true' === $lower || '1' === $lower ) { + return true; + } + if ( 'false' === $lower || '0' === $lower ) { + return false; + } + + // Other strings are invalid (e.g., 'yes', 'no', empty string). + return null; + } + + // All other types (arrays, objects, floats, null) are invalid. + return null; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpNameSanitizer.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpNameSanitizer.php new file mode 100644 index 0000000000..366c12db96 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpNameSanitizer.php @@ -0,0 +1,114 @@ + 128. + if ( strlen( $name ) > self::MAX_LENGTH ) { + $hash = substr( md5( $original ), 0, self::HASH_LENGTH ); + $name = substr( $name, 0, self::TRUNCATE_LENGTH ) . '-' . $hash; + } + + // Step 6: Final check - only empty is possible failure after sanitization. + // Characters are guaranteed valid (replaced), length is handled (truncated). + if ( empty( $name ) ) { + return new WP_Error( + 'mcp_name_invalid', + sprintf( + /* translators: %s: original ability name */ + __( 'Unable to derive valid MCP name from: %s', 'mcp-adapter' ), + $original + ) + ); + } + + return $name; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpValidator.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpValidator.php new file mode 100644 index 0000000000..e3d35f012d --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/McpValidator.php @@ -0,0 +1,513 @@ + $max_length ) { + return false; + } + + // Only allow letters, numbers, hyphens, underscores, and dots per MCP spec. + return (bool) preg_match( '/^[a-zA-Z0-9_.-]+$/', $name ); + } + + /** + * Validate base64 content. + * + * Checks if a string is valid base64-encoded content. + * + * @param string $content The content to validate as base64. + * + * @return bool True if valid base64, false otherwise. + */ + public static function validate_base64( string $content ): bool { + // Base64 content should not be empty. + if ( empty( $content ) ) { + return false; + } + + // Reject whitespace-only strings (they decode to empty string but aren't valid base64 content). + if ( trim( $content ) === '' ) { + return false; + } + + // Check if it's valid base64 encoding. + return base64_decode( $content, true ) !== false; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + } + + /** + * Validate an array of icons. + * + * Returns valid icons and logs warnings for invalid ones. + * Invalid icons are filtered out (graceful degradation). + * + * @param array $icons Array of icon data. + * @param bool $log_warnings Whether to log warnings for invalid icons. Default true. + * + * @return array{valid: array, errors: array} Array with 'valid' icons and 'errors' details. + * @since 0.5.0 + * + */ + public static function validate_icons_array( array $icons, bool $log_warnings = true ): array { + $valid_icons = array(); + $all_errors = array(); + + foreach ( $icons as $index => $icon ) { + if ( ! is_array( $icon ) ) { + $all_errors[] = array( + 'index' => $index, + 'errors' => array( __( 'Icon must be an array', 'mcp-adapter' ) ), + ); + continue; + } + + $errors = self::get_icon_validation_errors( $icon ); + + if ( empty( $errors ) ) { + $valid_icons[] = $icon; + } else { + $all_errors[] = array( + 'index' => $index, + 'errors' => $errors, + ); + + if ( $log_warnings ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( + sprintf( + 'MCP Adapter: Invalid icon at index %d skipped: %s', + $index, + implode( '; ', $errors ) + ) + ); + } + } + } + + return array( + 'valid' => $valid_icons, + 'errors' => $all_errors, + ); + } + + /** + * Get validation errors for an MCP icon object. + * + * Validates icon fields per MCP 2025-11-25 specification: + * - src (required): Valid URL or data: URI + * - mimeType (optional): String emitted as declared + * - sizes (optional): Array of size strings in WxH format or "any" + * - theme (optional): "light" or "dark" + * + * @param array $icon The icon data to validate. + * + * @return array Array of validation errors, empty if valid. + * @since 0.5.0 + * + */ + public static function get_icon_validation_errors( array $icon ): array { + $errors = array(); + + // src is required. + if ( ! isset( $icon['src'] ) ) { + $errors[] = __( 'Icon must have a src field', 'mcp-adapter' ); + } elseif ( ! is_string( $icon['src'] ) ) { + $errors[] = __( 'Icon src must be a string', 'mcp-adapter' ); + } elseif ( ! self::validate_icon_src( $icon['src'] ) ) { + $errors[] = __( 'Icon src must be a valid URL (http/https) or data: URI', 'mcp-adapter' ); + } + + // mimeType is optional. Only its type is checked. + if ( isset( $icon['mimeType'] ) && ! is_string( $icon['mimeType'] ) ) { + $errors[] = __( 'Icon mimeType must be a string', 'mcp-adapter' ); + } + + // sizes is optional but must be valid if present. + if ( isset( $icon['sizes'] ) ) { + if ( ! is_array( $icon['sizes'] ) ) { + $errors[] = __( 'Icon sizes must be an array', 'mcp-adapter' ); + } else { + foreach ( $icon['sizes'] as $index => $size ) { + if ( ! is_string( $size ) ) { + $errors[] = sprintf( + /* translators: %d: array index */ + __( 'Icon size at index %d must be a string', 'mcp-adapter' ), + $index + ); + } elseif ( ! self::validate_icon_size( $size ) ) { + $errors[] = sprintf( + /* translators: 1: size value, 2: array index */ + __( 'Icon size "%1$s" at index %2$d must be in WxH format (e.g., "48x48") or "any"', 'mcp-adapter' ), + $size, + $index + ); + } + } + } + } + + // theme is optional but must be valid if present. + if ( isset( $icon['theme'] ) ) { + if ( ! is_string( $icon['theme'] ) ) { + $errors[] = __( 'Icon theme must be a string', 'mcp-adapter' ); + } elseif ( ! self::validate_icon_theme( $icon['theme'] ) ) { + $errors[] = __( 'Icon theme must be "light" or "dark"', 'mcp-adapter' ); + } + } + + return $errors; + } + + /** + * Validate an icon source (src) value. + * + * Icon src must be a valid URL (http/https) or a data: URI with base64-encoded image data. + * + * @param string $src The icon source to validate. + * + * @return bool True if valid, false otherwise. + * @since 0.5.0 + * + */ + public static function validate_icon_src( string $src ): bool { + $src = trim( $src ); + + if ( empty( $src ) ) { + return false; + } + + // Check for data: URI. + if ( str_starts_with( $src, 'data:' ) ) { + // data:[][;base64], + // Simplified validation: must have data: prefix and contain comma. + return str_contains( $src, ',' ); + } + + // Check for http/https URL. + if ( str_starts_with( $src, 'http://' ) || str_starts_with( $src, 'https://' ) ) { + return filter_var( $src, FILTER_VALIDATE_URL ) !== false; + } + + return false; + } + + /** + * Validate an icon size string. + * + * Icon sizes must be in WxH format (e.g., "48x48", "96x96") or "any" for scalable formats. + * Both width and height must be positive integers (no zero dimensions, no leading zeros). + * + * @param string $size The size string to validate. + * + * @return bool True if valid, false otherwise. + * @since 0.5.0 + * + */ + public static function validate_icon_size( string $size ): bool { + $size = trim( $size ); + + if ( empty( $size ) ) { + return false; + } + + // "any" is valid for scalable formats like SVG. + if ( 'any' === strtolower( $size ) ) { + return true; + } + + // Must match WxH format with positive integers (no zero dimensions, no leading zeros). + // [1-9]\d* matches: 1, 2, ..., 9, 10, 11, ..., 99, 100, etc. + return (bool) preg_match( '/^[1-9]\d*x[1-9]\d*$/', $size ); + } + + /** + * Validate an icon theme value. + * + * Valid themes are "light" or "dark". + * + * @param string $theme The theme to validate. + * + * @return bool True if valid, false otherwise. + * @since 0.5.0 + * + */ + public static function validate_icon_theme( string $theme ): bool { + return in_array( strtolower( trim( $theme ) ), array( 'light', 'dark' ), true ); + } + + /** + * Get validation errors for shared MCP annotations. + * + * Validates shared annotation fields per MCP 2025-11-25 specification: + * - audience must be an array of valid Role values ("user", "assistant") + * - lastModified must be a valid ISO 8601 formatted string + * - priority must be a number between 0.0 and 1.0 + * + * Only validates known shared annotation fields. Unknown fields are ignored. + * Used by resources and content types (text, image, audio). + * + * Note: Tools use ToolAnnotations which is a separate type validated by McpToolValidator. + * + * @param array $annotations The annotations to validate. + * + * @return array Array of validation errors, empty if valid. + */ + public static function get_annotation_validation_errors( array $annotations ): array { + $errors = array(); + + foreach ( $annotations as $field => $value ) { + switch ( $field ) { + case 'audience': + if ( ! is_array( $value ) ) { + $errors[] = __( 'Annotation field audience must be an array', 'mcp-adapter' ); + break; + } + if ( ! self::validate_roles_array( $value ) ) { + $errors[] = __( 'Annotation field audience must contain only valid roles ("user" or "assistant")', 'mcp-adapter' ); + } + break; + + case 'lastModified': + if ( ! is_string( $value ) || empty( trim( $value ) ) ) { + $errors[] = __( 'Annotation field lastModified must be a non-empty string', 'mcp-adapter' ); + break; + } + if ( ! self::validate_iso8601_timestamp( trim( $value ) ) ) { + $errors[] = __( 'Annotation field lastModified must be a valid ISO 8601 timestamp', 'mcp-adapter' ); + } + break; + + case 'priority': + if ( ! is_numeric( $value ) ) { + $errors[] = __( 'Annotation field priority must be a number', 'mcp-adapter' ); + break; + } + if ( ! self::validate_priority( $value ) ) { + $errors[] = __( 'Annotation field priority must be between 0.0 and 1.0', 'mcp-adapter' ); + } + break; + + default: + // Unknown fields are ignored to allow forward compatibility. + break; + } + } + + return $errors; + } + + /** + * Validate an array of roles according to MCP specification. + * + * All roles must be strings and must be either "user" or "assistant". + * + * @param array $roles The roles array to validate. + * + * @return bool True if all roles are valid, false otherwise. + */ + public static function validate_roles_array( array $roles ): bool { + foreach ( $roles as $role ) { + if ( ! is_string( $role ) || ! self::validate_role( $role ) ) { + return false; + } + } + + return true; + } + + /** + * Validate a role value according to MCP specification. + * + * Valid roles are "user" or "assistant". + * + * @param string $role The role to validate. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_role( string $role ): bool { + return in_array( $role, array( 'user', 'assistant' ), true ); + } + + /** + * Validate ISO 8601 timestamp format. + * + * Checks if a string is a valid ISO 8601 timestamp by attempting to parse + * it using multiple ISO 8601 format variations. + * + * @param string $timestamp The timestamp to validate. + * + * @return bool True if valid ISO 8601 timestamp, false otherwise. + */ + public static function validate_iso8601_timestamp( string $timestamp ): bool { + // Try to parse as DateTime with ISO 8601 format. + $datetime = DateTime::createFromFormat( DateTime::ATOM, $timestamp ); + if ( $datetime && $datetime->format( DateTime::ATOM ) === $timestamp ) { + return true; + } + + // Try alternative ISO 8601 formats. + $formats = array( + 'Y-m-d\TH:i:s\Z', // UTC format + 'Y-m-d\TH:i:sP', // With timezone offset + 'Y-m-d\TH:i:s.u\Z', // With microseconds UTC + 'Y-m-d\TH:i:s.uP', // With microseconds and timezone + ); + + foreach ( $formats as $format ) { + $datetime = DateTime::createFromFormat( $format, $timestamp ); + if ( $datetime && $datetime->format( $format ) === $timestamp ) { + return true; + } + } + + return false; + } + + /** + * Validate a priority value according to MCP specification. + * + * Priority must be a number between 0.0 and 1.0 (inclusive). + * + * @param mixed $priority The priority value to validate. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_priority( $priority ): bool { + if ( ! is_numeric( $priority ) ) { + return false; + } + + $priority_float = (float) $priority; + + return $priority_float >= 0.0 && $priority_float <= 1.0; + } + + /** + * Normalize a `_meta` value for inclusion in a protocol DTO. + * + * MCP declares `_meta` as `{ [key: string]: unknown }` — a JSON object. PHP has one + * array type for both JSON shapes, so a sequential array (including an empty one) + * would serialize to a JSON array and put non-conformant output on the wire. Those + * are treated as absent, as is any non-array value. + * + * Returns null rather than raising so an incorrectly shaped optional `_meta` does + * not withhold the payload it accompanies. + * + * @since 0.6.0 + * + * @param mixed $meta The raw `_meta` value. + * + * @return array|null A non-empty, non-list array suitable for JSON-object encoding, or null if absent/invalid. + */ + public static function normalize_meta( $meta ): ?array { + if ( ! is_array( $meta ) || array() === $meta ) { + return null; + } + + // A list serializes to a JSON array. array_is_list() needs PHP 8.1; the floor is 7.4. + if ( array_keys( $meta ) === range( 0, count( $meta ) - 1 ) ) { + return null; + } + + return $meta; + } + + /** + * Validate a resource URI format. + * + * Per MCP spec: "The URI can use any protocol; it is up to the server how to interpret it." + * This validates basic URI structure per RFC 3986. + * + * @param string $uri The URI to validate. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_resource_uri( string $uri ): bool { + // URI should not be empty. + if ( empty( $uri ) ) { + return false; + } + + // Check reasonable length constraints. + if ( strlen( $uri ) > 2048 ) { + return false; + } + + // Basic URI validation: must have scheme followed by colon (RFC 3986). + // This accepts any protocol as per MCP specification. + return (bool) preg_match( '/^' . self::URI_SCHEME_PATTERN . ':.+/', $uri ); + } + + /** + * Lowercase the scheme (the part before the first ":") of a URI. + * + * URI schemes are case-insensitive per RFC 3986, so "Foo://x" and "foo://x" + * identify the same resource. Lowercasing the scheme on both sides of a + * comparison lets the two forms match. Everything after the scheme is left + * untouched, because case may be meaningful there. + * + * @param string $uri Resource URI. + * + * @return string Same URI with a lowercased scheme. + * @since 0.6.0 + */ + public static function fold_uri_scheme( string $uri ): string { + // On PCRE failure preg_replace_callback() returns null; keep the URI as-is. + return preg_replace_callback( + '/^(' . self::URI_SCHEME_PATTERN . '):/', + static fn( array $matches ): string => strtolower( $matches[1] ) . ':', + $uri + ) ?? $uri; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/SchemaTransformer.php b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/SchemaTransformer.php new file mode 100644 index 0000000000..638410b5dd --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Domain/Utils/SchemaTransformer.php @@ -0,0 +1,138 @@ +|null $schema The JSON schema to transform. + * @param string $wrapper_key Property name to use when wrapping non-object schemas. + * + * @return array Array containing 'schema', 'was_transformed' (bool), and 'wrapper_property' when transformed. + */ + public static function transform_to_object_schema( ?array $schema, string $wrapper_key = 'input' ): array { + // Convert any objects to arrays and strip empty properties. + // Abilities may contain objects from JSON decode cycles. Empty properties must be removed + // because MCP expects properties to be a JSON object, and PHP serializes empty arrays as []. + $schema = self::normalize( $schema ); + + // Handle null or empty schema - return minimal valid MCP object schema. + if ( empty( $schema ) ) { + return array( + 'schema' => array( + 'type' => 'object', + ), + 'was_transformed' => false, + 'wrapper_property' => null, + ); + } + + // If no type is specified, add 'object' type since MCP requires it. + if ( ! isset( $schema['type'] ) ) { + $schema['type'] = 'object'; + + return array( + 'schema' => $schema, + 'was_transformed' => false, + 'wrapper_property' => null, + ); + } + + // If already an object type, return as-is + if ( 'object' === $schema['type'] ) { + return array( + 'schema' => $schema, + 'was_transformed' => false, + 'wrapper_property' => null, + ); + } + + // Transform flattened schema to object format + return array( + 'schema' => self::wrap_in_object( $schema, $wrapper_key ), + 'was_transformed' => true, + 'wrapper_property' => $wrapper_key, + ); + } + + /** + * Wrap a flattened schema in an object structure. + * + * Creates an object schema with a single property (named by $wrapper_key) that + * contains the original flattened schema. + * + * @param array $schema The flattened schema to wrap. + * @param string $wrapper_key Property name to wrap the value under. + * + * @return array The wrapped object schema. + */ + private static function wrap_in_object( array $schema, string $wrapper_key ): array { + return array( + 'type' => 'object', + 'properties' => array( + $wrapper_key => $schema, + ), + 'required' => array( $wrapper_key ), + ); + } + + /** + * Convert objects to arrays and strip empty properties. + * + * @param array|null $schema The schema to normalize. + * + * @return array|null The normalized schema. + */ + private static function normalize( ?array $schema ): ?array { + if ( null === $schema ) { + return null; + } + + $schema = self::convert_objects_to_arrays( $schema ); + + if ( array_key_exists( 'properties', $schema ) && is_array( $schema['properties'] ) && empty( $schema['properties'] ) ) { + unset( $schema['properties'] ); + } + + return $schema; + } + + /** + * Recursively convert objects to arrays. + * + * @param mixed $value The value to convert. + * + * @return mixed The converted value. + */ + private static function convert_objects_to_arrays( $value ) { + if ( is_object( $value ) ) { + $value = (array) $value; + } + + if ( is_array( $value ) ) { + return array_map( array( self::class, 'convert_objects_to_arrays' ), $value ); + } + + return $value; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Handlers/HandlerHelperTrait.php b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/HandlerHelperTrait.php new file mode 100644 index 0000000000..74dc041d3e --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/HandlerHelperTrait.php @@ -0,0 +1,64 @@ +log( + 'Filter returned non-array value, falling back to original list', + array( + 'filter' => $filter_name, + 'returned_type' => gettype( $filtered ), + ), + 'warning' + ); + + return $original; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Initialize/InitializeHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Initialize/InitializeHandler.php new file mode 100644 index 0000000000..05f7f97396 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Initialize/InitializeHandler.php @@ -0,0 +1,100 @@ +mcp = $mcp; + } + + /** + * Handles the initialize request. + * + * Negotiates the protocol version with the client using McpVersionNegotiator. + * If the client requests a supported version, that version is used. Otherwise + * the server falls back to the latest supported version. + * + * @since 0.5.0 + * + * @param string $client_protocol_version The protocol version requested by the client. + * + * @return \WP\McpSchema\Common\Protocol\DTO\InitializeResult Response with server capabilities and information. + */ + public function handle( string $client_protocol_version ): InitializeResult { + $negotiated_version = McpVersionNegotiator::negotiate( $client_protocol_version ); + + $server_info = Implementation::fromArray( + array( + 'name' => $this->mcp->get_server_name(), + 'version' => $this->mcp->get_server_version(), + ) + ); + + // Capabilities should only be advertised if they are implemented end-to-end. + // IMPORTANT: We set explicit boolean values (not empty arrays) to ensure proper JSON serialization. + // Empty arrays `[]` serialize as JSON arrays `[]`, but MCP spec requires JSON objects `{}`. + // Setting explicit values like `listChanged: false` produces associative arrays that serialize correctly. + $capabilities = ServerCapabilities::fromArray( + array( + 'prompts' => array( 'listChanged' => false ), + 'resources' => array( + 'subscribe' => false, + 'listChanged' => false, + ), + 'tools' => array( 'listChanged' => false ), + ) + ); + + $result = InitializeResult::fromArray( + array( + 'protocolVersion' => $negotiated_version, + 'capabilities' => $capabilities, + 'serverInfo' => $server_info, + 'instructions' => $this->mcp->get_server_description(), + ) + ); + + /** + * Filters the initialize response before returning to the client. + * + * Use this filter to modify server capabilities, instructions, or + * other initialization data dynamically. To modify the result, call + * `$result->toArray()`, change the data, and return + * `InitializeResult::fromArray( $modified_data )`. + * + * @since 0.5.0 + * + * @param \WP\McpSchema\Common\Protocol\DTO\InitializeResult $result The initialize result DTO. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + return apply_filters( 'mcp_adapter_initialize_response', $result, $this->mcp ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Prompts/PromptsHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Prompts/PromptsHandler.php new file mode 100644 index 0000000000..b095f0f780 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Prompts/PromptsHandler.php @@ -0,0 +1,657 @@ + + */ + private static array $valid_content_types = array( 'text', 'image', 'audio', 'resource_link', 'resource' ); + + /** + * Valid role values for PromptMessage. + * + * @var list + */ + private static array $valid_roles = array( 'user', 'assistant' ); + + /** + * Default role for messages when not specified. + * + * @var string + */ + private static string $default_role = 'user'; + + /** + * The WordPress MCP instance. + * + * @var \WP\MCP\Core\McpServer + */ + private McpServer $mcp; + + /** + * Constructor. + * + * @param \WP\MCP\Core\McpServer $mcp The WordPress MCP instance. + */ + public function __construct( McpServer $mcp ) { + $this->mcp = $mcp; + } + + /** + * Handles the prompts/list request. + * + * @return \WP\McpSchema\Server\Prompts\DTO\ListPromptsResult Response with prompts list DTO. + */ + public function list_prompts(): ListPromptsResult { + $prompts = array_values( $this->mcp->get_prompts() ); + + /** + * Filters the list of prompts before returning to the client. + * + * Use this filter to filter prompts by context, add dynamic prompts, + * or reorder the prompts list. + * + * @since 0.5.0 + * + * @param array<\WP\McpSchema\Server\Prompts\DTO\Prompt> $prompts Array of Prompt DTOs. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $prompts = $this->validate_filtered_list( + apply_filters( 'mcp_adapter_prompts_list', $prompts, $this->mcp ), + $prompts, + 'mcp_adapter_prompts_list', + $this->mcp->get_error_handler() + ); + + return ListPromptsResult::fromArray( + array( + 'prompts' => $prompts, + ) + ); + } + + /** + * Handles the prompts/get request. + * + * @param array $params Request parameters. + * @param string|int|null $request_id Optional. The request ID for JSON-RPC. Default 0. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse Response with prompt execution results or error. + */ + public function get_prompt( array $params, $request_id = 0 ) { + // Extract parameters using helper method. + $request_params = $this->extract_params( $params ); + + if ( ! isset( $request_params['name'] ) ) { + return McpErrorFactory::missing_parameter( $request_id, 'name' ); + } + + $prompt_name = (string) $request_params['name']; + $prompt_name = trim( $prompt_name ); + + if ( isset( $request_params['arguments'] ) && ! is_array( $request_params['arguments'] ) ) { + return McpErrorFactory::invalid_params( $request_id, 'arguments must be an object' ); + } + + $mcp_prompt = $this->mcp->get_mcp_prompt( $prompt_name ); + + if ( ! $mcp_prompt ) { + return McpErrorFactory::prompt_not_found( $request_id, $prompt_name ); + } + + /** @var \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt */ + $prompt = $mcp_prompt->get_protocol_dto(); + + // Get the arguments for the prompt. + $arguments = $request_params['arguments'] ?? array(); + + try { + $permission = $mcp_prompt->check_permission( $arguments ); + if ( true !== $permission ) { + $error_message = 'Access denied for prompt: ' . $prompt_name; + if ( is_wp_error( $permission ) ) { + $error_message = $permission->get_error_message(); + } + + return McpErrorFactory::permission_denied( $request_id, $error_message ); + } + + /** + * Filters prompt arguments before execution, or short-circuits execution entirely. + * + * Return the (optionally modified) arguments array to proceed with execution, + * or return a WP_Error to block execution and return an error to the client. + * + * @since 0.5.0 + * + * @param array $arguments The prompt arguments. + * @param string $prompt_name The prompt name being retrieved. + * @param \WP\MCP\Domain\Prompts\McpPrompt $mcp_prompt The MCP prompt instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $arguments = apply_filters( 'mcp_adapter_pre_prompt_get', $arguments, $prompt_name, $mcp_prompt, $this->mcp ); + + // Allow pre-filter to short-circuit execution by returning WP_Error. + if ( is_wp_error( $arguments ) ) { + return McpErrorFactory::internal_error( $request_id, $arguments->get_error_message() ); + } + + $result = $mcp_prompt->execute( $arguments ); + + /** + * Filters the prompt execution result before normalization. + * + * Use this filter for message transformation, context injection, + * content enrichment, or audit logging. + * + * @since 0.5.0 + * + * @param mixed|\WP_Error $result The raw execution result (may be WP_Error). + * @param array $arguments The prompt arguments used. + * @param string $prompt_name The prompt name. + * @param \WP\MCP\Domain\Prompts\McpPrompt $mcp_prompt The MCP prompt instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $result = apply_filters( 'mcp_adapter_prompt_get_result', $result, $arguments, $prompt_name, $mcp_prompt, $this->mcp ); + + if ( is_wp_error( $result ) ) { + $this->mcp->get_error_handler()->log( + 'Prompt execution returned WP_Error', + array( + 'prompt_name' => $prompt_name, + 'error_code' => $result->get_error_code(), + 'error_message' => $result->get_error_message(), + ) + ); + + return McpErrorFactory::internal_error( $request_id, $result->get_error_message() ); + } + + return $this->normalize_result_to_dto( $result, $prompt, $prompt_name ); + } catch ( \Throwable $e ) { + $this->mcp->get_error_handler()->log( + 'Prompt execution failed', + array( + 'prompt_name' => $prompt_name, + 'arguments' => $arguments, + 'error' => $e->getMessage(), + ) + ); + + return McpErrorFactory::internal_error( $request_id, 'Prompt execution failed' ); + } + } + + // ========================================================================= + // Result Normalization (Tiered Convenience Shortcuts) + // ========================================================================= + + /** + * Normalize and convert prompt execution result to GetPromptResult DTO. + * + * Supports tiered return formats: + * - Tier 1: Full MCP format with 'messages' array + * - Tier 2: Simple 'text' shorthand + * - Tier 3: Single message with 'role' and 'content' + * - Tier 4: Multi-text with 'texts' array + * - Tier 5: Fallback JSON encoding for arbitrary data + * + * @since 0.5.0 + * + * @param array $result Raw result from prompt execution. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO for description fallback. + * @param string $prompt_name Prompt name for logging. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_result_to_dto( + array $result, + PromptDto $prompt, + string $prompt_name + ): GetPromptResult { + // Tier 1: Full MCP format with 'messages' array. + if ( isset( $result['messages'] ) && is_array( $result['messages'] ) ) { + return $this->normalize_tier1_messages( $result, $prompt, $prompt_name ); + } + + // Tier 2: Simple 'text' shorthand. + if ( isset( $result['text'] ) && is_string( $result['text'] ) ) { + return $this->normalize_tier2_text( $result, $prompt ); + } + + // Tier 3: Single message with 'role' key. + if ( isset( $result['role'] ) && isset( $result['content'] ) ) { + return $this->normalize_tier3_single_message( $result, $prompt, $prompt_name ); + } + + // Tier 4: Multi-text with 'texts' array. + if ( isset( $result['texts'] ) && is_array( $result['texts'] ) ) { + return $this->normalize_tier4_texts( $result, $prompt ); + } + + // Tier 5: Fallback - JSON encode arbitrary data. + return $this->normalize_tier5_fallback( $result, $prompt, $prompt_name ); + } + + /** + * Tier 1: Full MCP-compliant format with 'messages' array. + * + * @since 0.5.0 + * + * @param array $result Raw result with 'messages' key. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * @param string $prompt_name Prompt name for logging. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_tier1_messages( + array $result, + PromptDto $prompt, + string $prompt_name + ): GetPromptResult { + $message_dtos = array(); + + foreach ( $result['messages'] as $index => $message ) { + if ( ! is_array( $message ) ) { + $this->mcp->get_error_handler()->log( + 'Invalid message structure in prompt result, skipping', + array( + 'prompt_name' => $prompt_name, + 'message_index' => $index, + 'message_type' => gettype( $message ), + ), + 'warning' + ); + continue; + } + + $message_dtos[] = $this->validate_and_create_message( $message, $prompt_name ); + } + + // Ensure we have at least one message. + if ( empty( $message_dtos ) ) { + $message_dtos[] = PromptMessage::fromArray( + array( + 'role' => self::$default_role, + 'content' => array( + 'type' => 'text', + 'text' => '(No messages returned)', + ), + ) + ); + } + + return GetPromptResult::fromArray( + array( + 'messages' => $message_dtos, + 'description' => $result['description'] ?? $prompt->getDescription(), + ) + ); + } + + /** + * Tier 2: Simple 'text' shorthand. + * + * Creates a single user message with text content. + * + * @since 0.5.0 + * + * @param array $result Raw result with 'text' key. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_tier2_text( array $result, PromptDto $prompt ): GetPromptResult { + $content = array( + 'type' => 'text', + 'text' => (string) $result['text'], + ); + + // Support optional annotations on the text. + if ( isset( $result['annotations'] ) && is_array( $result['annotations'] ) ) { + $content['annotations'] = $result['annotations']; + } + + $message_dto = PromptMessage::fromArray( + array( + 'role' => self::$default_role, + 'content' => $content, + ) + ); + + return GetPromptResult::fromArray( + array( + 'messages' => array( $message_dto ), + 'description' => $result['description'] ?? $prompt->getDescription(), + ) + ); + } + + /** + * Tier 3: Single message with 'role' and 'content'. + * + * @since 0.5.0 + * + * @param array $result Raw result with 'role' and 'content' keys. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * @param string $prompt_name Prompt name for logging. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_tier3_single_message( + array $result, + PromptDto $prompt, + string $prompt_name + ): GetPromptResult { + $message_dto = $this->validate_and_create_message( $result, $prompt_name ); + + return GetPromptResult::fromArray( + array( + 'messages' => array( $message_dto ), + 'description' => $result['description'] ?? $prompt->getDescription(), + ) + ); + } + + /** + * Tier 4: Multi-text with 'texts' array. + * + * Creates multiple messages with the same role. + * + * @since 0.5.0 + * + * @param array $result Raw result with 'texts' key. + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_tier4_texts( array $result, PromptDto $prompt ): GetPromptResult { + $role = $this->validate_role( $result['role'] ?? self::$default_role, '' ); + $message_dtos = array(); + + foreach ( $result['texts'] as $text ) { + if ( ! is_string( $text ) ) { + continue; + } + + $message_dtos[] = PromptMessage::fromArray( + array( + 'role' => $role, + 'content' => array( + 'type' => 'text', + 'text' => $text, + ), + ) + ); + } + + // Ensure we have at least one message. + if ( empty( $message_dtos ) ) { + $message_dtos[] = PromptMessage::fromArray( + array( + 'role' => $role, + 'content' => array( + 'type' => 'text', + 'text' => '(No texts provided)', + ), + ) + ); + } + + return GetPromptResult::fromArray( + array( + 'messages' => $message_dtos, + 'description' => $result['description'] ?? $prompt->getDescription(), + ) + ); + } + + /** + * Tier 5: Fallback - JSON encode arbitrary data. + * + * Used when no other tier matches. Logs an observability event. + * + * @since 0.5.0 + * + * @param array $result Raw result (arbitrary structure). + * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO. + * @param string $prompt_name Prompt name for logging. + * + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult + */ + private function normalize_tier5_fallback( + array $result, + PromptDto $prompt, + string $prompt_name + ): GetPromptResult { + // Log observability event for fallback normalization. + $this->mcp->get_observability_handler()->record_event( + 'prompt_result_fallback_normalization', + array( + 'prompt_name' => $prompt_name, + 'result_keys' => array_keys( $result ), + ) + ); + + $json_content = wp_json_encode( $result, JSON_PRETTY_PRINT ); + if ( false === $json_content ) { + $json_content = '{}'; + } + + $message_dto = PromptMessage::fromArray( + array( + 'role' => self::$default_role, + 'content' => array( + 'type' => 'text', + 'text' => $json_content, + ), + ) + ); + + return GetPromptResult::fromArray( + array( + 'messages' => array( $message_dto ), + 'description' => $prompt->getDescription(), + ) + ); + } + + // ========================================================================= + // Validation Helpers + // ========================================================================= + + /** + * Validate message structure and create PromptMessage DTO. + * + * Validates role and content type, applying defaults where needed. + * + * @since 0.5.0 + * + * @param array $message Raw message array. + * @param string $prompt_name Prompt name for logging. + * + * @return \WP\McpSchema\Server\Prompts\DTO\PromptMessage + */ + private function validate_and_create_message( array $message, string $prompt_name ): PromptMessage { + // Validate and normalize role. + $role = $this->validate_role( $message['role'] ?? self::$default_role, $prompt_name ); + + // Validate and normalize content. + $content = $message['content'] ?? array(); + if ( ! is_array( $content ) ) { + // If content is a string, wrap it as text. + $content = array( + 'type' => 'text', + 'text' => is_string( $content ) ? $content : (string) $content, + ); + } + + $content = $this->validate_content_type( $content, $prompt_name ); + $content = $this->normalize_content_block( $content ); + + return PromptMessage::fromArray( + array( + 'role' => $role, + 'content' => $content, + ) + ); + } + + /** + * Bring a caller-supplied content block into the shape the schema DTOs accept. + * + * Prompt messages carry the same content blocks tool results do, and reach the wire + * through the same DTOs, so they carry the same hazard: a `_meta` that would serialize + * as a JSON array where MCP declares an object. + * + * Here the cost is higher than on the tool path. A value the DTO refuses throws, and + * the catch in get_prompt() turns that into an error response - so a `_meta` that is + * not an array at all loses the whole prompt rather than the field. It is dropped so + * that the message survives. + * + * @since 0.6.0 + * + * @param array $content Content block as the prompt returned it. + * + * @return array Content block safe to hand to PromptMessage::fromArray(). + */ + private function normalize_content_block( array $content ): array { + $block_meta = McpValidator::normalize_meta( $content['_meta'] ?? null ); + if ( null === $block_meta ) { + unset( $content['_meta'] ); + } else { + $content['_meta'] = $block_meta; + } + + // EmbeddedResource takes its resource contents as given, so a nested block never + // reaches a DTO that could reject them. This is the only level that inspects them. + if ( 'resource' === ( $content['type'] ?? '' ) && isset( $content['resource'] ) && is_array( $content['resource'] ) ) { + $resource = $content['resource']; + + $resource_meta = McpValidator::normalize_meta( $resource['_meta'] ?? null ); + if ( null === $resource_meta ) { + unset( $resource['_meta'] ); + } else { + $resource['_meta'] = $resource_meta; + } + $content['resource'] = $resource; + } + + return $content; + } + + /** + * Validate content type against ContentBlockFactory registry. + * + * @since 0.5.0 + * + * @param array $content Content array with 'type' key. + * @param string $prompt_name Prompt name for logging. + * + * @return array Validated content array (may be modified if invalid type). + */ + private function validate_content_type( array $content, string $prompt_name ): array { + $type = $content['type'] ?? null; + + // Check if type is missing. + if ( null === $type || '' === $type ) { + $this->mcp->get_error_handler()->log( + 'Missing content type in prompt result, defaulting to text', + array( + 'prompt_name' => $prompt_name, + ), + 'warning' + ); + + $text = isset( $content['text'] ) ? (string) $content['text'] : wp_json_encode( $content, JSON_PRETTY_PRINT ); + + return array( + 'type' => 'text', + 'text' => false === $text ? '{}' : $text, + ); + } + + // Check if type is valid. + if ( ! in_array( $type, self::$valid_content_types, true ) ) { + $this->mcp->get_error_handler()->log( + 'Invalid content type in prompt result, converting to text', + array( + 'prompt_name' => $prompt_name, + 'invalid_type' => $type, + 'valid_types' => self::$valid_content_types, + ), + 'warning' + ); + + // Convert the entire content to a text representation. + $json_content = wp_json_encode( $content, JSON_PRETTY_PRINT ); + if ( false === $json_content ) { + $json_content = '{}'; + } + + return array( + 'type' => 'text', + 'text' => $json_content, + ); + } + + // Type is valid, return content as-is (preserves annotations). + return $content; + } + + /** + * Validate role value and apply default if invalid. + * + * @since 0.5.0 + * + * @param string $role Role value to validate. + * @param string $prompt_name Prompt name for logging (empty to skip logging). + * + * @return string Valid role value. + */ + private function validate_role( string $role, string $prompt_name ): string { + if ( in_array( $role, self::$valid_roles, true ) ) { + return $role; + } + + if ( '' !== $prompt_name ) { + $this->mcp->get_error_handler()->log( + 'Invalid role in prompt message, defaulting to user', + array( + 'prompt_name' => $prompt_name, + 'invalid_role' => $role, + 'valid_roles' => self::$valid_roles, + ), + 'warning' + ); + } + + return self::$default_role; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Resources/ResourcesHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Resources/ResourcesHandler.php new file mode 100644 index 0000000000..aed8dadbe9 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/Resources/ResourcesHandler.php @@ -0,0 +1,318 @@ +mcp = $mcp; + } + + + /** + * Handles the resources/list request. + * + * Returns a ListResourcesResult DTO containing all registered resources. + * Returns protocol DTOs as-is; any `_meta` fields are passed through unchanged. + * + * @return \WP\McpSchema\Server\Resources\DTO\ListResourcesResult Response with resources list. + */ + public function list_resources(): ListResourcesResult { + $resources = array_values( $this->mcp->get_resources() ); + + /** + * Filters the list of resources before returning to the client. + * + * Use this filter to filter resources by context, add dynamic resources, + * or reorder the resources list. + * + * @since 0.5.0 + * + * @param array<\WP\McpSchema\Server\Resources\DTO\Resource> $resources Array of Resource DTOs. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $resources = $this->validate_filtered_list( + apply_filters( 'mcp_adapter_resources_list', $resources, $this->mcp ), + $resources, + 'mcp_adapter_resources_list', + $this->mcp->get_error_handler() + ); + + return ListResourcesResult::fromArray( + array( + 'resources' => $resources, + ) + ); + } + + /** + * Handles the resources/templates/list request. + * + * The adapter has no resource-template concept, so this always returns an empty + * list. The method still needs a handler: `resources/templates/list` is part of + * the base `resources` capability (no sub-flag gates it), which the server always + * advertises, so spec-compliant clients call it during resource discovery. + * + * @return \WP\McpSchema\Server\Resources\DTO\ListResourceTemplatesResult Empty resource-templates list. + */ + public function list_resource_templates(): ListResourceTemplatesResult { + return ListResourceTemplatesResult::fromArray( + array( + 'resourceTemplates' => array(), + ) + ); + } + + /** + * Handles the resources/read request. + * + * Returns either a ReadResourceResult DTO (for success) or a JSONRPCErrorResponse DTO + * (for protocol errors like missing parameter or resource not found). + * + * Unlike tools, resources don't have a concept of "execution errors" that should be + * reported with isError=true. Resource reads either succeed or fail at the protocol level. + * + * @param array $params Request parameters. + * @param string|int|null $request_id Optional. The request ID for JSON-RPC. Default 0. + * + * @return \WP\McpSchema\Server\Resources\DTO\ReadResourceResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public function read_resource( array $params, $request_id = 0 ) { + // Extract parameters using helper method. + $request_params = $this->extract_params( $params ); + + if ( ! isset( $request_params['uri'] ) ) { + return McpErrorFactory::missing_parameter( $request_id, 'uri' ); + } + + $uri = $request_params['uri']; + $uri = is_string( $uri ) ? trim( $uri ) : ''; + + $mcp_resource = $this->mcp->get_mcp_resource( $uri ); + if ( ! $mcp_resource ) { + return McpErrorFactory::resource_not_found( $request_id, $uri ); + } + + /** @var \WP\McpSchema\Server\Resources\DTO\Resource $resource */ + $resource = $mcp_resource->get_protocol_dto(); + + try { + $has_permission = $mcp_resource->check_permission( $request_params ); + if ( true !== $has_permission ) { + // Extract detailed error message if WP_Error was returned. + $error_message = 'Access denied for resource: ' . $resource->getName(); + + if ( is_wp_error( $has_permission ) ) { + $error_message = $has_permission->get_error_message(); + } + + return McpErrorFactory::permission_denied( $request_id, $error_message ); + } + + /** + * Filters resource parameters before execution, or short-circuits execution entirely. + * + * Return the (optionally modified) parameters array to proceed with execution, + * or return a WP_Error to block execution and return an error to the client. + * + * @since 0.5.0 + * + * @param array $params The request parameters. + * @param string $uri The resource URI. + * @param \WP\MCP\Domain\Resources\McpResource $mcp_resource The MCP resource instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $request_params = apply_filters( 'mcp_adapter_pre_resource_read', $request_params, $uri, $mcp_resource, $this->mcp ); + + // Allow pre-filter to short-circuit execution by returning WP_Error. + if ( is_wp_error( $request_params ) ) { + return McpErrorFactory::internal_error( $request_id, $request_params->get_error_message() ); + } + + $contents = $mcp_resource->execute( $request_params ); + + /** + * Filters the resource contents after execution. + * + * Use this filter for content transformation, caching storage, + * PII redaction, or audit logging. + * + * @since 0.5.0 + * + * @param mixed|\WP_Error $contents The raw resource contents (may be WP_Error). + * @param array $params The request parameters used. + * @param string $uri The resource URI. + * @param \WP\MCP\Domain\Resources\McpResource $mcp_resource The MCP resource instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $contents = apply_filters( 'mcp_adapter_resource_read_result', $contents, $request_params, $uri, $mcp_resource, $this->mcp ); + + // Handle WP_Error objects returned by McpResource execution. + if ( is_wp_error( $contents ) ) { + $this->mcp->get_error_handler()->log( + 'Resource execution returned WP_Error object', + array( + 'uri' => $uri, + 'error_code' => $contents->get_error_code(), + 'error_message' => $contents->get_error_message(), + ) + ); + + return McpErrorFactory::internal_error( $request_id, $contents->get_error_message() ); + } + + // Successful execution - convert contents to DTOs. + // Contents should be an array of resource content items. + // If it's already an array of properly formatted items, convert each to a DTO. + // Otherwise, wrap the result as text content. + // + // Seed the fallback content URI with the advertised URI, not the client's + // request URI, so contents[].uri matches resources/list even when the client + // lowercased the scheme (RFC 3986 3.1). For an exact-case read the two are equal. + $content_dtos = $this->convert_contents_to_dtos( $contents, $resource->getUri() ); + + return ReadResourceResult::fromArray( + array( + 'contents' => $content_dtos, + ) + ); + } catch ( \Throwable $exception ) { + $this->mcp->get_error_handler()->log( + 'Error reading resource', + array( + 'uri' => $uri, + 'exception' => $exception->getMessage(), + ) + ); + + return McpErrorFactory::internal_error( $request_id, 'Failed to read resource' ); + } + } + + /** + * Convert ability execution results to resource content DTOs. + * + * The MCP spec expects contents to be an array of TextResourceContents or BlobResourceContents. + * This method handles various return formats from abilities and normalizes them. + * + * @param mixed $contents The contents returned by the ability. + * @param string $uri The resource URI. + * + * @return array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> + */ + private function convert_contents_to_dtos( $contents, string $uri ): array { + // If contents is already an array of properly structured items, convert each. + if ( is_array( $contents ) && ! empty( $contents ) ) { + // Check if this is an array of content items (has 'uri', 'text', or 'blob' in first item). + $first_item = reset( $contents ); + if ( is_array( $first_item ) && ( isset( $first_item['uri'] ) || isset( $first_item['text'] ) || isset( $first_item['blob'] ) ) ) { + return array_map( + function ( $item ) use ( $uri ) { + return $this->create_content_dto( $item, $uri ); + }, + $contents + ); + } + } + + // Fallback: wrap as a single text content item. + if ( is_string( $contents ) ) { + $text = $contents; + } else { + $text = wp_json_encode( $contents ); + if ( false === $text ) { + $text = '{}'; + } + } + + return array( + TextResourceContents::fromArray( + array( + 'uri' => $uri, + 'text' => $text, + ) + ), + ); + } + + /** + * Create a content DTO from an array item. + * + * `_meta` is carried through from the item so metadata a handler attaches to its + * resource contents reaches the client. MCP Apps UI resources rely on this: they + * put CSP config and border hints under `_meta.ui` alongside the HTML body. + * + * A list-shaped `_meta` is omitted because MCP declares this field as a JSON object. + * + * Every key is optional and read defensively, because a handler returns whatever + * WordPress handed it: `blob` and `text` are cast to string, `mimeType` is kept + * only when it already is one, and an absent `uri` falls back to $default_uri. + * + * @param array{uri?: mixed, mimeType?: mixed, text?: mixed, blob?: mixed, _meta?: mixed} $item The content item array. + * @param string $default_uri The URI to use when the item names none. + * + * @return \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents + */ + private function create_content_dto( array $item, string $default_uri ) { + $item_uri = $item['uri'] ?? $default_uri; + $mime_type = $item['mimeType'] ?? null; + $meta = McpValidator::normalize_meta( $item['_meta'] ?? null ); + + // If there's blob data, create BlobResourceContents. + if ( isset( $item['blob'] ) ) { + return BlobResourceContents::fromArray( + array( + 'uri' => $item_uri, + 'blob' => (string) $item['blob'], + 'mimeType' => is_string( $mime_type ) ? $mime_type : null, + '_meta' => $meta, + ) + ); + } + + // Default to TextResourceContents. + $text = $item['text'] ?? ''; + + return TextResourceContents::fromArray( + array( + 'uri' => $item_uri, + 'text' => (string) $text, + 'mimeType' => is_string( $mime_type ) ? $mime_type : null, + '_meta' => $meta, + ) + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Handlers/System/SystemHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/System/SystemHandler.php new file mode 100644 index 0000000000..8290f4b4f4 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Handlers/System/SystemHandler.php @@ -0,0 +1,27 @@ +mcp = $mcp; + } + + /** + * Handles the tools/list/all request. + * + * This is a custom extension to the MCP spec that includes availability status. + * Returns a ListToolsResult DTO containing all registered tools. + * + * Note: The 'available' flag is a non-standard extension and is not currently implemented. + * + * @return \WP\McpSchema\Server\Tools\DTO\ListToolsResult Response with all tools. + */ + public function list_all_tools(): ListToolsResult { + // Return the standard tools list. + return $this->list_tools(); + } + + /** + * Handles the tools/list request. + * + * Returns a ListToolsResult DTO containing all registered tools. + * Tool DTOs are protocol-only; internal adapter metadata is stored in McpTool instances and is never exposed + * to MCP clients. + * + * @return \WP\McpSchema\Server\Tools\DTO\ListToolsResult Response with tools list. + */ + public function list_tools(): ListToolsResult { + $tools = array_values( $this->mcp->get_tools() ); + + /** + * Filters the list of tools before returning to the client. + * + * Use this filter to hide tools per user/role, add dynamic tools, + * or reorder the tools list. + * + * @since 0.5.0 + * + * @param array<\WP\McpSchema\Server\Tools\DTO\Tool> $tools Array of Tool DTOs. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $tools = $this->validate_filtered_list( + apply_filters( 'mcp_adapter_tools_list', $tools, $this->mcp ), + $tools, + 'mcp_adapter_tools_list', + $this->mcp->get_error_handler() + ); + + return ListToolsResult::fromArray( + array( + 'tools' => $tools, + ) + ); + } + + /** + * Handles the tools/call request. + * + * Returns either a CallToolResult DTO (for success or tool execution errors) + * or a JSONRPCErrorResponse DTO (for protocol errors like tool not found). + * + * The MCP spec distinguishes between: + * 1. **Protocol errors** (tool not found, server error) → JSONRPCErrorResponse + * 2. **Tool execution errors** (permission denied, runtime error) → CallToolResult with isError=true + * + * This distinction is critical for LLM self-correction - execution errors are + * visible to the LLM, while protocol errors indicate infrastructure issues. + * + * @param array $params Request params. + * @param string|int|null $request_id Optional. The request ID for JSON-RPC. Default 0. + * + * @return \WP\McpSchema\Server\Tools\DTO\CallToolResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public function call_tool( array $params, $request_id = 0 ) { + // Extract parameters using helper method. + $request_params = $this->extract_params( $params ); + + if ( ! isset( $request_params['name'] ) ) { + return McpErrorFactory::missing_parameter( $request_id, 'tool name' ); + } + + if ( isset( $request_params['arguments'] ) && ! is_array( $request_params['arguments'] ) ) { + return McpErrorFactory::invalid_params( $request_id, 'arguments must be an object' ); + } + + try { + $tool_name = trim( (string) $request_params['name'] ); + $args = $request_params['arguments'] ?? array(); + + $mcp_tool = $this->mcp->get_mcp_tool( $tool_name ); + if ( ! $mcp_tool ) { + $this->mcp->get_error_handler()->log( + 'Tool not found', + array( + 'tool_name' => $tool_name, + ), + 'warning' + ); + + return McpErrorFactory::tool_not_found( $request_id, $tool_name ); + } + + $permission = $mcp_tool->check_permission( $args ); + if ( true !== $permission ) { + $error_message = __( 'Permission denied', 'mcp-adapter' ); + if ( is_wp_error( $permission ) ) { + $error_message = $permission->get_error_message(); + + $this->mcp->get_error_handler()->log( + 'Tool permission check failed', + array( + 'tool_name' => $tool_name, + 'error_code' => $permission->get_error_code(), + 'error_message' => $permission->get_error_message(), + 'error_data' => $permission->get_error_data(), + 'failure_reason' => FailureReason::PERMISSION_CHECK_FAILED, + ) + ); + } + + return $this->create_error_result( $error_message ); + } + + /** + * Filters tool arguments before execution, or short-circuits execution entirely. + * + * Return the (optionally modified) arguments array to proceed with execution, + * or return a WP_Error to block execution and return an error to the client. + * + * @since 0.5.0 + * + * @param array $args The tool arguments. + * @param string $tool_name The tool name being called. + * @param \WP\MCP\Domain\Tools\McpTool $mcp_tool The MCP tool instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $args = apply_filters( 'mcp_adapter_pre_tool_call', $args, $tool_name, $mcp_tool, $this->mcp ); + + // Allow pre-filter to short-circuit execution by returning WP_Error. + if ( is_wp_error( $args ) ) { + return $this->create_error_result( $args->get_error_message() ); + } + + $result = $mcp_tool->execute( $args ); + + /** + * Filters the tool execution result before response assembly. + * + * Use this filter for result transformation, PII redaction, + * audit logging, or content enrichment. + * + * @since 0.5.0 + * + * @param mixed|\WP_Error $result The raw execution result (may be WP_Error). + * @param array $args The tool arguments used. + * @param string $tool_name The tool name that was called. + * @param \WP\MCP\Domain\Tools\McpTool $mcp_tool The MCP tool instance. + * @param \WP\MCP\Core\McpServer $server The MCP server instance. + */ + $result = apply_filters( 'mcp_adapter_tool_call_result', $result, $args, $tool_name, $mcp_tool, $this->mcp ); + + if ( is_wp_error( $result ) ) { + $this->mcp->get_error_handler()->log( + 'Tool execution returned WP_Error', + array( + 'tool_name' => $tool_name, + 'error_code' => $result->get_error_code(), + 'error_message' => $result->get_error_message(), + 'error_data' => $result->get_error_data(), + ) + ); + + return $this->create_error_result( $result->get_error_message() ); + } + + // Backward compatibility: treat `{ success: false, error: string }` as tool execution error. + if ( + is_array( $result ) + && array_key_exists( 'success', $result ) + && false === $result['success'] + && isset( $result['error'] ) + && is_string( $result['error'] ) + && '' !== trim( $result['error'] ) + ) { + return $this->create_error_result( $result['error'] ); + } + + // Successful tool execution - build CallToolResult DTO. + + // Handle embedded resource results (MCP ContentBlock type: "resource"). + // This allows tools to return text/blob resources using the MCP schema's EmbeddedResource content block. + // + // Two shapes are accepted, and they place `_meta` differently: + // + // - Nested `{ type, resource: { uri, text, _meta }, _meta }` maps one-to-one onto + // the DTO tree, so the outer `_meta` belongs to the content block and the inner + // one to the resource contents. + // - Flat `{ type, uri, mimeType, text, _meta }` is a resource-contents literal + // carrying a `type` tag: every key beside `type` is a `ResourceContents` field, + // and `_meta` is declared there alongside them. Its `_meta` therefore describes + // the resource, which is what the same literal already means to + // `ResourcesHandler::create_content_dto()`. A caller who needs block-level + // `_meta` writes the nested form, which exists to express that distinction. + if ( isset( $result['type'] ) && 'resource' === $result['type'] ) { + $is_nested = isset( $result['resource'] ) && is_array( $result['resource'] ); + $resource_item = $is_nested ? $result['resource'] : $result; + + $uri = $resource_item['uri'] ?? null; + $mime_type = $resource_item['mimeType'] ?? null; + + if ( is_string( $uri ) ) { + $uri = trim( $uri ); + } + + // Only return an EmbeddedResource if we have a valid URI and some content. + $has_text = isset( $resource_item['text'] ) && is_string( $resource_item['text'] ); + $has_blob = isset( $resource_item['blob'] ) && is_string( $resource_item['blob'] ); + + if ( is_string( $uri ) && '' !== $uri && ( $has_text || $has_blob ) ) { + $block_meta = $is_nested + ? McpValidator::normalize_meta( $result['_meta'] ?? null ) + : null; + $resource_meta = McpValidator::normalize_meta( $resource_item['_meta'] ?? null ); + + if ( $has_text ) { + return CallToolResult::fromArray( + array( + 'content' => array( + ContentBlockHelper::embedded_text_resource( + $uri, + $resource_item['text'], + is_string( $mime_type ) ? $mime_type : null, + null, + $block_meta, + $resource_meta + ), + ), + 'isError' => false, + ) + ); + } + + if ( $has_blob ) { + return CallToolResult::fromArray( + array( + 'content' => array( + ContentBlockHelper::embedded_blob_resource( + $uri, + $resource_item['blob'], + is_string( $mime_type ) ? $mime_type : null, + null, + $block_meta, + $resource_meta + ), + ), + 'isError' => false, + ) + ); + } + } + } + + // Handle image results. + // + // `type` marks this result as a description of a content block rather than tool + // data, so its sibling `_meta` is the block's, which is the reading the `resource` + // branch above already applies to the same key. + if ( isset( $result['type'] ) && 'image' === $result['type'] && isset( $result['results'] ) ) { + $image_data = base64_encode( $result['results'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $mime_type = $result['mimeType'] ?? self::DEFAULT_IMAGE_MIME_TYPE; + + return CallToolResult::fromArray( + array( + 'content' => array( + ContentBlockHelper::image( + $image_data, + $mime_type, + null, + McpValidator::normalize_meta( $result['_meta'] ?? null ) + ), + ), + 'structuredContent' => null, + 'isError' => false, + ) + ); + } + + // The generic fallback carries no `type` marker, so every key it holds is tool + // data: the result is JSON-encoded into the text block and returned verbatim as + // `structuredContent`. Reading `_meta` off it would give one key two meanings, + // with nothing to tell metadata from a domain field. + + // Standard result - JSON-encode for text content, include as structuredContent. + $json_text = wp_json_encode( $result ); + if ( false === $json_text ) { + $json_text = '{}'; + } + + return CallToolResult::fromArray( + array( + 'content' => array( ContentBlockHelper::text( $json_text ) ), + 'structuredContent' => $result, + 'isError' => false, + ) + ); + } catch ( \Throwable $exception ) { + $this->mcp->get_error_handler()->log( + 'Error calling tool', + array( + 'tool' => $request_params['name'], + 'exception' => $exception->getMessage(), + ) + ); + + return McpErrorFactory::internal_error( $request_id, 'Failed to execute tool' ); + } + } + + /** + * Create an error CallToolResult from a message string. + * + * @since 0.5.0 + * + * @param string $message The error message. + * + * @return \WP\McpSchema\Server\Tools\DTO\CallToolResult + */ + private function create_error_result( string $message ): CallToolResult { + return CallToolResult::fromArray( + array( + 'content' => array( ContentBlockHelper::text( $message ) ), + 'structuredContent' => null, + 'isError' => true, + ) + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/Contracts/McpErrorHandlerInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/Contracts/McpErrorHandlerInterface.php new file mode 100644 index 0000000000..9f4de09213 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/Contracts/McpErrorHandlerInterface.php @@ -0,0 +1,30 @@ + McpConstants::JSONRPC_VERSION, + 'error' => self::create_error( $code, $message, $data ), + 'id' => $id, + ) + ); + } + + /** + * Create an Error DTO. + * + * @param int $code The error code. + * @param string $message The error message. + * @param mixed|null $data Optional additional error data. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\Error + */ + public static function create_error( int $code, string $message, $data = null ): Error { + return Error::fromArray( + array( + 'code' => $code, + 'message' => $message, + 'data' => $data, + ) + ); + } + + /** + * Create a method not found error response. + * + * @param string|int|null $id The request ID. + * @param string $method The method that was not found. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function method_not_found( $id, string $method ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::METHOD_NOT_FOUND, + sprintf( + /* translators: %s: method name */ + __( 'Method not found: %s', 'mcp-adapter' ), + $method + ) + ); + } + + /** + * Create an invalid params error response. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function invalid_params( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Invalid params', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::INVALID_PARAMS, $message ); + } + + /** + * Create an internal error response. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function internal_error( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Internal error', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::INTERNAL_ERROR, $message ); + } + + /** + * Create an MCP disabled error response. + * + * @param string|int|null $id The request ID. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function mcp_disabled( $id ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::SERVER_ERROR, + __( 'MCP functionality is currently disabled', 'mcp-adapter' ) + ); + } + + /** + * Create a validation error response (uses standard invalid params error). + * + * @param string|int|null $id The request ID. + * @param string $details Validation error details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function validation_error( $id, string $details ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::INVALID_PARAMS, + sprintf( + /* translators: %s: validation details */ + __( 'Validation error: %s', 'mcp-adapter' ), + $details + ) + ); + } + + /** + * Create a missing parameter error response. + * + * @param string|int|null $id The request ID. + * @param string $parameter The missing parameter name. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function missing_parameter( $id, string $parameter ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::INVALID_PARAMS, + sprintf( + /* translators: %s: parameter name */ + __( 'Missing required parameter: %s', 'mcp-adapter' ), + $parameter + ) + ); + } + + /** + * Create a resource not found error response. + * + * @param string|int|null $id The request ID. + * @param string $resource_uri The resource identifier. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function resource_not_found( $id, string $resource_uri ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::RESOURCE_NOT_FOUND, + sprintf( + /* translators: %s: resource identifier */ + __( 'Resource not found: %s', 'mcp-adapter' ), + $resource_uri + ) + ); + } + + /** + * Create a tool not found error response. + * + * @param string|int|null $id The request ID. + * @param string $tool The tool name. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function tool_not_found( $id, string $tool ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::TOOL_NOT_FOUND, + sprintf( + /* translators: %s: tool name */ + __( 'Tool not found: %s', 'mcp-adapter' ), + $tool + ) + ); + } + + /** + * Create an ability not found error response. + * + * @param string|int|null $id The request ID. + * @param string $ability The ability name. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function ability_not_found( $id, string $ability ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::TOOL_NOT_FOUND, + sprintf( + /* translators: %s: ability name */ + __( 'Ability not found: %s', 'mcp-adapter' ), + $ability + ) + ); + } + + /** + * Create a prompt not found error response. + * + * @param string|int|null $id The request ID. + * @param string $prompt The prompt name. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function prompt_not_found( $id, string $prompt ): JSONRPCErrorResponse { + return self::create_error_response( + $id, + self::PROMPT_NOT_FOUND, + sprintf( + /* translators: %s: prompt name */ + __( 'Prompt not found: %s', 'mcp-adapter' ), + $prompt + ) + ); + } + + /** + * Create a session not found error response. + * + * Used when an MCP session ID is invalid or expired. Maps to HTTP 404 + * per the MCP specification requirement for invalid/expired sessions. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function session_not_found( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Session not found', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::SESSION_NOT_FOUND, $message ); + } + + /** + * Create a permission denied error response. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function permission_denied( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Permission denied', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::PERMISSION_DENIED, $message ); + } + + /** + * Create an unauthorized error response. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function unauthorized( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Unauthorized', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::UNAUTHORIZED, $message ); + } + + /** + * Determine if an MCP error should return HTTP 200 or an HTTP error status. + * + * This method helps distinguish between transport-level errors (which should + * return HTTP error codes) and application-level errors (which should return + * HTTP 200 with a JSON-RPC error response). + * + * @param \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse|array $error_response The MCP error response (DTO or array). + * + * @return int The appropriate HTTP status code. + */ + public static function get_http_status_for_error( $error_response ): int { + // Handle DTO + if ( $error_response instanceof JSONRPCErrorResponse ) { + return self::mcp_error_to_http_status( $error_response->getError()->getCode() ); + } + + // Handle legacy array format + if ( ! isset( $error_response['error']['code'] ) ) { + return 500; // Invalid error response structure + } + + return self::mcp_error_to_http_status( $error_response['error']['code'] ); + } + + /** + * Translate MCP error code to appropriate HTTP status code. + * + * Maps JSON-RPC error codes to HTTP status codes according to best practices: + * - Transport-level errors (malformed JSON-RPC) → HTTP 4xx + * - Application-level errors (business logic) → HTTP 200 with JSON-RPC error + * + * @param int|string|float $mcp_error_code The MCP/JSON-RPC error code (integer, float, or string). + * + * @return int The appropriate HTTP status code. + */ + public static function mcp_error_to_http_status( $mcp_error_code ): int { + // Cast to integer for comparison (handles float from DTOs) + $code = is_numeric( $mcp_error_code ) ? (int) $mcp_error_code : 0; + + switch ( $code ) { + // Transport-level errors - these indicate malformed requests + case self::PARSE_ERROR: // Invalid JSON - syntactic error + return 400; + + case self::INVALID_REQUEST: // Invalid JSON-RPC structure - syntactic error + return 400; + + // Authentication and authorization errors + case self::UNAUTHORIZED: // Authentication required + return 401; + + case self::PERMISSION_DENIED: // Access forbidden + return 403; + + // Resource not found errors + case self::RESOURCE_NOT_FOUND: + case self::TOOL_NOT_FOUND: + case self::PROMPT_NOT_FOUND: + case self::SESSION_NOT_FOUND: + case self::METHOD_NOT_FOUND: + return 404; + + // Server errors + case self::INTERNAL_ERROR: + case self::SERVER_ERROR: + return 500; + + case self::TIMEOUT_ERROR: + return 504; + + // Application-level errors - return 200 with JSON-RPC error + case self::INVALID_PARAMS: + default: + return 200; + } + } + + /** + * Validate JSON-RPC message structure. + * + * @param mixed $message The message to validate. + * + * @return true|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse Returns true if valid, or JSONRPCErrorResponse DTO if invalid. + */ + public static function validate_jsonrpc_message( $message ) { + if ( ! is_array( $message ) ) { + return self::invalid_request( null, __( 'Message must be a JSON object', 'mcp-adapter' ) ); + } + + // Must have jsonrpc field with value "2.0". + if ( ! isset( $message['jsonrpc'] ) || McpConstants::JSONRPC_VERSION !== $message['jsonrpc'] ) { + return self::invalid_request( + null, + sprintf( + /* translators: %s: JSON-RPC version */ + __( 'jsonrpc version must be "%s"', 'mcp-adapter' ), + McpConstants::JSONRPC_VERSION + ) + ); + } + + // Must be either a request/notification (has method) or a response (has result/error). + $is_request_or_notification = isset( $message['method'] ); + $is_response = isset( $message['result'] ) || isset( $message['error'] ); + + if ( ! $is_request_or_notification && ! $is_response ) { + return self::invalid_request( null, __( 'Message must have either method or result/error field', 'mcp-adapter' ) ); + } + + // Responses must have an id field. + if ( $is_response && ! isset( $message['id'] ) ) { + return self::invalid_request( null, __( 'Response messages must have an id field', 'mcp-adapter' ) ); + } + + return true; + } + + /** + * Create an invalid request error response. + * + * @param string|int|null $id The request ID. + * @param string $details Optional additional details. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + public static function invalid_request( $id, string $details = '' ): JSONRPCErrorResponse { + $message = __( 'Invalid Request', 'mcp-adapter' ); + if ( $details ) { + $message .= ': ' . $details; + } + + return self::create_error_response( $id, self::INVALID_REQUEST, $message ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/NullMcpErrorHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/NullMcpErrorHandler.php new file mode 100644 index 0000000000..18f0ba640f --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/ErrorHandling/NullMcpErrorHandler.php @@ -0,0 +1,35 @@ + $formatted_event, + 'duration_ms' => $duration_ms, + 'tags' => $merged_tags, + 'timestamp' => gmdate( 'Y-m-d H:i:s' ), + ); + + // Pretty print JSON for readability + $json = wp_json_encode( $output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + + // Output with visual separator + $separator = str_repeat( '=', 80 ); + $message = "\n{$separator}\n[MCP OBSERVABILITY EVENT]\n{$separator}\n{$json}\n{$separator}\n"; + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( $message ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/Contracts/McpObservabilityHandlerInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/Contracts/McpObservabilityHandlerInterface.php new file mode 100644 index 0000000000..b091184b34 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/Contracts/McpObservabilityHandlerInterface.php @@ -0,0 +1,31 @@ + List of all valid failure reason constants. + */ + public static function all(): array { + return array( + // Registration. + self::ABILITY_NOT_FOUND, + self::DUPLICATE_URI, + self::BUILDER_EXCEPTION, + self::NO_PERMISSION_STRATEGY, + self::ABILITY_CONVERSION_FAILED, + // Permission. + self::PERMISSION_DENIED, + self::PERMISSION_CHECK_FAILED, + // Execution. + self::NOT_FOUND, + self::EXECUTION_FAILED, + self::EXECUTION_EXCEPTION, + // Validation. + self::MISSING_PARAMETER, + self::INVALID_PARAMETER, + ); + } + + /** + * Check if a value is a valid failure reason. + * + * @param string $value The value to check. + * + * @return bool True if valid, false otherwise. + */ + public static function is_valid( string $value ): bool { + return in_array( $value, self::all(), true ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/McpObservabilityHelperTrait.php b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/McpObservabilityHelperTrait.php new file mode 100644 index 0000000000..f049520175 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/McpObservabilityHelperTrait.php @@ -0,0 +1,219 @@ + 'arguments', + \Error::class => 'system', + \InvalidArgumentException::class => 'validation', + \LogicException::class => 'logic', + \RuntimeException::class => 'execution', + \TypeError::class => 'type', + ); + + /** + * Patterns that indicate sensitive data in keys or values. + * + * These patterns are designed to match both camelCase and snake_case variants: + * - apiKey, api_key, API_KEY + * - authToken, auth_token, AUTH_TOKEN + * - secretKey, secret_key, SECRET_KEY + * + * @var string[] + */ + private static array $sensitive_patterns = array( + 'password', + 'passwd', + 'pwd', + 'secret', + 'token', + 'bearer', + 'credential', + 'private', + 'apikey', + 'api_key', + 'authtoken', + 'auth_token', + 'accesstoken', + 'access_token', + 'refreshtoken', + 'refresh_token', + 'clientsecret', + 'client_secret', + 'privatekey', + 'private_key', + 'secretkey', + 'secret_key', + 'authorization', + 'authenticate', + 'encryption', + ); + + /** + * Format metric name to follow consistent naming conventions. + * + * @param string $metric The raw metric name. + * + * @return string + */ + public static function format_metric_name( string $metric ): string { + // Ensure metric starts with 'mcp.' prefix. + if ( ! str_starts_with( $metric, 'mcp.' ) ) { + $metric = 'mcp.' . $metric; + } + + // Convert to lowercase and replace spaces/special chars with dots. + $metric = strtolower( $metric ); + $metric = (string) preg_replace( '/[^a-z0-9_\.]/', '.', $metric ); + $metric = (string) preg_replace( '/\.+/', '.', $metric ); // Remove duplicate dots. + // Remove leading/trailing dots. + + return trim( $metric, '.' ); + } + + /** + * Merge default tags with provided tags. + * + * @param array $tags The user-provided tags. + * + * @return array + */ + public static function merge_tags( array $tags ): array { + $default_tags = self::get_default_tags(); + $merged_tags = array_merge( $default_tags, $tags ); + + return self::sanitize_tags( $merged_tags ); + } + + /** + * Get default tags that should be included with all metrics. + * + * @return array + */ + public static function get_default_tags(): array { + return array( + 'site_id' => function_exists( 'get_current_blog_id' ) ? get_current_blog_id() : 0, + 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + 'timestamp' => time(), + ); + } + + /** + * Sanitize tags to ensure they are safe for logging and don't contain sensitive data. + * + * @param array $tags The tags to sanitize. + * + * @return array + */ + public static function sanitize_tags( array $tags ): array { + $sanitized = array(); + + foreach ( $tags as $key => $value ) { + // Convert key to string and limit length to prevent log bloat. + $key = substr( (string) $key, 0, 64 ); + + // Check if the key itself indicates sensitive data. + if ( self::is_sensitive_key( $key ) ) { + $sanitized[ $key ] = '[REDACTED]'; + continue; + } + + // Convert value to string, handling null specially. + if ( null === $value ) { + $value = ''; + } elseif ( is_scalar( $value ) ) { + $value = (string) $value; + } else { + $value = wp_json_encode( $value ); + // wp_json_encode can return false on failure, ensure we have a string. + if ( false === $value ) { + $value = ''; + } + } + + // Limit value length to prevent log bloat. + if ( strlen( $value ) > 1024 ) { + $value = substr( $value, 0, 1024 ) . '...[truncated]'; + } + + // Remove potentially sensitive information patterns from values. + $value = self::redact_sensitive_values( $value ); + + $sanitized[ $key ] = $value; + } + + return $sanitized; + } + + /** + * Check if a key name indicates sensitive data. + * + * Matches patterns in camelCase, snake_case, and SCREAMING_CASE. + * + * @param string $key The key name to check. + * + * @return bool True if the key appears to contain sensitive data. + */ + public static function is_sensitive_key( string $key ): bool { + // Normalize: lowercase and remove underscores/hyphens for pattern matching. + $normalized = strtolower( str_replace( array( '_', '-' ), '', $key ) ); + + foreach ( self::$sensitive_patterns as $pattern ) { + // Remove underscores from pattern for normalized comparison. + $normalized_pattern = str_replace( '_', '', $pattern ); + + if ( str_contains( $normalized, $normalized_pattern ) ) { + return true; + } + } + + return false; + } + + /** + * Redact sensitive values from a string. + * + * Uses a more comprehensive pattern that catches compound words. + * + * @param string $value The value to redact. + * + * @return string The value with sensitive patterns redacted. + */ + public static function redact_sensitive_values( string $value ): string { + // Build a regex pattern that matches sensitive words as substrings. + // This catches camelCase (apiKey), snake_case (api_key), and standalone words. + $pattern = '/(?:' . implode( '|', array_map( 'preg_quote', self::$sensitive_patterns ) ) . ')/i'; + + return (string) preg_replace( $pattern, '[REDACTED]', $value ); + } + + /** + * Categorize an exception into a general error category. + * + * @param \Throwable $exception The exception to categorize. + * + * @return string + */ + public static function categorize_error( \Throwable $exception ): string { + return self::$error_categories[ get_class( $exception ) ] ?? 'unknown'; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/NullMcpObservabilityHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/NullMcpObservabilityHandler.php new file mode 100644 index 0000000000..4ff523da51 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Infrastructure/Observability/NullMcpObservabilityHandler.php @@ -0,0 +1,36 @@ +setup(); + + /** + * Fires after the main plugin class has been initialized. + * + * @since 0.1.0 + * + * @param self $instance The main plugin class instance. + */ + do_action( 'wp_mcp_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Sets up the plugin. + */ + private function setup(): void { + // Bail if dependencies are not met. + if ( ! $this->has_dependencies() ) { + return; + } + + McpAdapter::instance(); + } + + /** + * Checks if all required dependencies are available. + * + * Will log an admin notice if dependencies are missing. + * + * @return bool True if all dependencies are met, false otherwise. + */ + private function has_dependencies(): bool { + // Check if Abilities API is available. + if ( ! function_exists( 'wp_register_ability' ) ) { + add_action( + 'admin_notices', + static function () { + wp_admin_notice( + __( 'MCP Adapter requires WordPress 6.9 or newer. The Abilities API is included in WordPress core.', 'mcp-adapter' ), + array( + 'type' => 'error', + 'dismiss' => false, + ), + ); + } + ); + + return false; + } + + return true; + } + + /** + * Prevents the class from being cloned. + */ + public function __clone() { + _doing_it_wrong( + __FUNCTION__, + sprintf( + // translators: %s: Class name. + esc_html__( 'The %s class should not be cloned.', 'mcp-adapter' ), + esc_html( self::class ), + ), + '0.1.0' + ); + } + + /** + * Prevents the class from being deserialized. + */ + public function __wakeup() { + _doing_it_wrong( + __FUNCTION__, + sprintf( + // translators: %s: Class name. + esc_html__( 'De-serializing instances of %s is not allowed.', 'mcp-adapter' ), + esc_html( self::class ), + ), + '0.1.0' + ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Servers/DefaultServerFactory.php b/lib/vendor/wordpress/mcp-adapter/includes/Servers/DefaultServerFactory.php new file mode 100644 index 0000000000..6ea353ab57 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Servers/DefaultServerFactory.php @@ -0,0 +1,167 @@ + 'mcp-adapter-default-server', + 'server_route_namespace' => 'mcp', + 'server_route' => 'mcp-adapter-default-server', + 'server_name' => 'MCP Adapter Default Server', + 'server_description' => 'Default MCP server for WordPress abilities discovery and execution', + 'server_version' => 'v1.0.0', + 'mcp_transports' => array( HttpTransport::class ), + 'error_handler' => ErrorLogMcpErrorHandler::class, + 'observability_handler' => NullMcpObservabilityHandler::class, + 'tools' => array( + 'mcp-adapter/discover-abilities', + 'mcp-adapter/get-ability-info', + 'mcp-adapter/execute-ability', + ), + 'resources' => $auto_discovered_resources, + 'prompts' => $auto_discovered_prompts, + ); + + /** + * Filters the default MCP server configuration. + * + * Allows customization of the default server's settings before creation. + * The filtered array is merged with defaults, so you only need to specify + * the values you want to override. + * + * @since 0.3.0 + * + * @param array $config { + * Default server configuration. + * + * @type string $server_id Server identifier. Default 'mcp-adapter-default-server'. + * @type string $server_route_namespace REST API namespace. Default 'mcp-adapter/v1'. + * @type string $server_route REST API route. Default 'mcp'. + * @type string $server_name Human-readable name. Default 'WordPress MCP Server'. + * @type string $server_description Server description. + * @type string $server_version Server version. Default WORDPRESS_MCP_ADAPTER_VERSION. + * @type string[] $mcp_transports Transport class names. Default [HttpTransport::class]. + * @type string $error_handler Error handler class. Default ErrorLogMcpErrorHandler::class. + * @type string $observability_handler Observability handler class. Default NullMcpObservabilityHandler::class. + * @type string[] $tools Tool ability names to expose. + * @type string[] $resources Resource ability names to expose. + * @type string[] $prompts Prompt ability names to expose. + * } + */ + $config = apply_filters( 'mcp_adapter_default_server_config', $wordpress_defaults ); + + // Ensure config is an array and merge with defaults + if ( ! is_array( $config ) ) { + $config = $wordpress_defaults; + } + $config = wp_parse_args( $config, $wordpress_defaults ); + + // Use McpAdapter to create the server with full validation + $adapter = McpAdapter::instance(); + $result = $adapter->create_server( + $config['server_id'], + $config['server_route_namespace'], + $config['server_route'], + $config['server_name'], + $config['server_description'], + $config['server_version'], + $config['mcp_transports'], + $config['error_handler'], + $config['observability_handler'], + $config['tools'], + $config['resources'], + $config['prompts'] + ); + + // Log error if server creation failed, but don't halt execution. + // This allows other servers to be registered even if default server fails. + if ( ! is_wp_error( $result ) ) { + return; + } + + _doing_it_wrong( + __METHOD__, + sprintf( + 'MCP Adapter: Failed to create default server. Error: %s (Code: %s)', + esc_html( $result->get_error_message() ), + esc_html( (string) $result->get_error_code() ) + ), + '0.5.0' + ); + } + + /** + * Discover abilities by MCP type. + * + * Scans all registered abilities and returns those with the specified type + * and public MCP exposure. + * + * @param string $type The MCP type to filter by ('tool', 'resource', or 'prompt'). + * + * @return array Array of ability names matching the specified type. + */ + private static function discover_abilities_by_type( string $type ): array { + $abilities = wp_get_abilities(); + $filtered = array(); + + foreach ( $abilities as $ability ) { + $ability_name = $ability->get_name(); + $meta = $ability->get_meta(); + + // Skip if not publicly exposed + if ( ! McpAbilityExposure::is_public( $ability ) ) { + continue; + } + + // Get the type (defaults to 'tool' if not specified) + $ability_type = $meta['mcp']['type'] ?? 'tool'; + + // Add to filtered list if type matches + if ( $ability_type !== $type ) { + continue; + } + + $filtered[] = $ability_name; + } + + return $filtered; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpRestTransportInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpRestTransportInterface.php new file mode 100644 index 0000000000..baef93452d --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpRestTransportInterface.php @@ -0,0 +1,38 @@ +> $request The WordPress REST request object. + * + * @return bool|\WP_Error True if allowed, WP_Error or false if not. + */ + public function check_permission( \WP_REST_Request $request ); + + /** + * Handle incoming REST requests. + * + * @param \WP_REST_Request> $request The WordPress REST request object. + * + * @return \WP_REST_Response REST API response object. + */ + public function handle_request( \WP_REST_Request $request ): \WP_REST_Response; +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpTransportInterface.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpTransportInterface.php new file mode 100644 index 0000000000..b07abb4e59 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Contracts/McpTransportInterface.php @@ -0,0 +1,38 @@ +request_handler = new HttpRequestHandler( $transport_context ); + add_action( 'rest_api_init', array( $this, 'register_routes' ), 16 ); + } + + /** + * Register MCP HTTP routes + */ + public function register_routes(): void { + // Get server info from request handler's transport context + $server = $this->request_handler->get_transport_context()->mcp_server; + + // Single endpoint for MCP communication (POST, GET reserved for SSE, DELETE for session termination). + // Do not remove GET: it is part of the MCP HTTP transport shape and will be implemented (SSE) in a future iteration. + register_rest_route( + $server->get_server_route_namespace(), + $server->get_server_route(), + array( + 'methods' => array( 'POST', 'GET', 'DELETE' ), + 'callback' => array( $this, 'handle_request' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Check if the user has permission to access the MCP API + * + * @param \WP_REST_Request> $request The request object. + * + * @return bool True if the user has permission, false otherwise. + */ + public function check_permission( \WP_REST_Request $request ) { + $context = new HttpRequestContext( $request ); + + // Check permission using callback or default + $transport_context = $this->request_handler->get_transport_context(); + + if ( null !== $transport_context->transport_permission_callback ) { + try { + $result = call_user_func( $transport_context->transport_permission_callback, $context->request ); + + // Handle WP_Error returns + if ( ! is_wp_error( $result ) ) { + // Cast to bool to match return type while preserving truthy/falsy semantics. + return (bool) $result; + } + + // Log the error and deny access (fail-closed) + $this->request_handler->get_transport_context()->error_handler->log( + 'Permission callback returned WP_Error: ' . $result->get_error_message(), + array( 'HttpTransport::check_permission' ) + ); + + return false; + } catch ( \Throwable $e ) { + // Log the error and deny access (fail-closed) + $this->request_handler->get_transport_context()->error_handler->log( 'Error in transport permission callback: ' . $e->getMessage(), array( 'HttpTransport::check_permission' ) ); + + return false; + } + } + + /** + * Filters the default user capability required for MCP transport access. + * + * This filter is only applied when no custom transport permission callback + * is provided. The capability is checked using current_user_can(). + * + * @since 0.3.0 + * + * @param string $capability The required capability. Default 'read'. + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + */ + $user_capability = apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', $context ); + + // Validate that the filtered capability is a non-empty string + if ( ! is_string( $user_capability ) || empty( $user_capability ) ) { + $user_capability = 'read'; + } + + $user_has_capability = current_user_can( $user_capability ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is filtered and defaults to 'read' + + if ( ! $user_has_capability ) { + $user_id = get_current_user_id(); + $this->request_handler->get_transport_context()->error_handler->log( + sprintf( 'Permission denied for MCP API access. User ID %d does not have capability "%s"', $user_id, $user_capability ), + array( 'HttpTransport::check_permission' ) + ); + } + + return $user_has_capability; + } + + /** + * Handle HTTP requests according to MCP 2025-11-25 specification + * + * @param \WP_REST_Request> $request The request object. + * + * @return \WP_REST_Response + */ + public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { + $context = new HttpRequestContext( $request ); + + return $this->request_handler->handle_request( $context ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestContext.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestContext.php new file mode 100644 index 0000000000..60b3dff9f3 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestContext.php @@ -0,0 +1,75 @@ +> + */ + public \WP_REST_Request $request; + + /** + * The HTTP method of the request. + * + * @var string + */ + public string $method; + + + /** + * The Mcp-Session-Id header from the request. + * + * @var string|null + */ + public ?string $session_id; + + /** + * The JSON-decoded body of the request. + * + * @var array|null + */ + public ?array $body; + + /** + * The MCP-Protocol-Version header from the request. + * + * @since 0.5.0 + * + * @var string|null + */ + public ?string $protocol_version; + + /** + * The Accept header from the request. + * + * @var string|null + */ + public ?string $accept_header; + + /** + * Constructor. + * + * @param \WP_REST_Request> $request The original request object. + */ + public function __construct( \WP_REST_Request $request ) { + $this->request = $request; + $this->method = $request->get_method(); + $this->session_id = $request->get_header( 'Mcp-Session-Id' ); + $this->protocol_version = $request->get_header( 'Mcp-Protocol-Version' ); + $this->accept_header = $request->get_header( 'accept' ); + $this->body = 'POST' === $this->method ? $request->get_json_params() : null; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestHandler.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestHandler.php new file mode 100644 index 0000000000..9914225aa8 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpRequestHandler.php @@ -0,0 +1,338 @@ +transport_context = $transport_context; + } + + /** + * Get the transport context. + * + * @since 0.5.0 + * + * @return \WP\MCP\Transport\Infrastructure\McpTransportContext + */ + public function get_transport_context(): McpTransportContext { + return $this->transport_context; + } + + /** + * Route HTTP request to appropriate handler. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return \WP_REST_Response HTTP response. + */ + public function handle_request( HttpRequestContext $context ): \WP_REST_Response { + // Handle POST requests (sending MCP messages to server) + if ( 'POST' === $context->method ) { + return $this->handle_mcp_request( $context ); + } + + // Handle GET requests (reserved for SSE streaming; currently not implemented). + if ( 'GET' === $context->method ) { + return $this->handle_sse_request(); + } + + // Handle DELETE requests (session termination) + if ( 'DELETE' === $context->method ) { + return $this->handle_session_termination( $context ); + } + + // Method not allowed + return new \WP_REST_Response( + McpErrorFactory::invalid_request( null, 'Method not allowed' )->toArray(), + 405 + ); + } + + + /** + * Handle MCP POST requests. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return \WP_REST_Response MCP response. + */ + private function handle_mcp_request( HttpRequestContext $context ): \WP_REST_Response { + try { + // Validate request body + if ( null === $context->body ) { + return new \WP_REST_Response( + McpErrorFactory::parse_error( null, 'Invalid JSON in request body' )->toArray(), + 400 + ); + } + + return $this->process_mcp_messages( $context ); + } catch ( \Throwable $exception ) { + $this->transport_context->mcp_server->get_error_handler()->log( + 'Unexpected error in handle_mcp_request', + array( + 'transport' => static::class, + 'server_id' => $this->transport_context->mcp_server->get_server_id(), + 'error' => $exception->getMessage(), + ) + ); + + return new \WP_REST_Response( + McpErrorFactory::internal_error( null, 'Handler error occurred' )->toArray(), + 500 + ); + } + } + + /** + * Process MCP messages using JsonRpcResponseBuilder. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return \WP_REST_Response MCP response. + */ + private function process_mcp_messages( HttpRequestContext $context ): \WP_REST_Response { + $is_batch_request = JsonRpcResponseBuilder::is_batch_request( $context->body ); + $messages = JsonRpcResponseBuilder::normalize_messages( $context->body ); + + $response_body = JsonRpcResponseBuilder::process_messages( + $messages, + $is_batch_request, + function ( array $message ) use ( $context ) { + return $this->process_single_message( $message, $context ); + } + ); + + // Per MCP spec 2025-06-18: Notifications return HTTP 202 Accepted with no body. + // A null response_body indicates only notifications were processed (no requests with IDs). + if ( null === $response_body ) { + return new \WP_REST_Response( null, 202 ); + } + + // Determine HTTP status code based on error type + if ( ! $is_batch_request && isset( $response_body['error'] ) ) { + $http_status = McpErrorFactory::get_http_status_for_error( $response_body ); + + return new \WP_REST_Response( $response_body, $http_status ); + } + + return new \WP_REST_Response( $response_body, 200 ); + } + + /** + * Process a single MCP message. + * + * @param array $message The MCP JSON-RPC message. + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return array|null JSON-RPC response or null for notifications. + */ + private function process_single_message( array $message, HttpRequestContext $context ): ?array { + // Validate JSON-RPC message format + $validation = McpErrorFactory::validate_jsonrpc_message( $message ); + if ( true !== $validation ) { + return $validation->toArray(); + } + + // Handle notifications (no response required) + if ( isset( $message['method'] ) && ! isset( $message['id'] ) ) { + return null; // Notifications don't get a response + } + + // Process requests with IDs + if ( isset( $message['method'] ) && isset( $message['id'] ) ) { + return $this->process_jsonrpc_request( $message, $context ); + } + + // JSON-RPC responses from client (has result/error, no method) also return null. + // Per MCP spec: client responses get HTTP 202 Accepted with no body, same as notifications. + return null; + } + + /** + * Process a JSON-RPC request message. + * + * @param array $message The JSON-RPC message. + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return array JSON-RPC response. + */ + private function process_jsonrpc_request( array $message, HttpRequestContext $context ): array { + $request_id = $message['id']; // Preserve original scalar ID (string, number, or null) + $method = $message['method']; + $params = $message['params'] ?? array(); + + // Validate session for all requests except initialize (router will handle initialize session creation) + if ( 'initialize' !== $method ) { + $session_validation = HttpSessionValidator::validate_session_with_error_handler( $context, $this->transport_context->error_handler ); + if ( true !== $session_validation ) { + return JsonRpcResponseBuilder::create_error_response( $request_id, $session_validation['error'] ?? $session_validation ); + } + + // Validate MCP-Protocol-Version header for non-initialize requests. + $protocol_version_error = $this->validate_protocol_version_header( $context ); + if ( null !== $protocol_version_error ) { + return JsonRpcResponseBuilder::create_error_response( $request_id, $protocol_version_error ); + } + } + + // Route the request through the transport context + $result = $this->transport_context->request_router->route_request( + $method, + $params, + $request_id, + $this->get_transport_name(), + $context + ); + + // Handle session header if provided by router + if ( isset( $result['_session_id'] ) ) { + $this->add_session_header_to_response( $result['_session_id'] ); + unset( $result['_session_id'] ); // Remove from actual response data + } + + // Format response based on result + if ( isset( $result['error'] ) ) { + return JsonRpcResponseBuilder::create_error_response( $request_id, $result['error'] ); + } + + return JsonRpcResponseBuilder::create_success_response( $request_id, $result ); + } + + /** + * Get transport name for observability. + * + * @return string Transport name. + */ + private function get_transport_name(): string { + return 'HTTP'; + } + + /** + * Validate the MCP-Protocol-Version header on non-initialize requests. + * + * A missing header is accepted (returns null). A header containing a supported + * version is also accepted. An unsupported version returns a JSON-RPC + * invalid-request error payload. + * + * @since 0.5.0 + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return array|null Null when the header is absent or valid, error payload otherwise. + */ + private function validate_protocol_version_header( HttpRequestContext $context ): ?array { + if ( null === $context->protocol_version ) { + return null; + } + + if ( McpVersionNegotiator::is_supported( $context->protocol_version ) ) { + return null; + } + + return McpErrorFactory::create_error( + McpErrorFactory::INVALID_REQUEST, + sprintf( + 'Bad Request: Unsupported protocol version: %s (supported versions: %s)', + $context->protocol_version, + implode( ', ', McpVersionNegotiator::SUPPORTED_PROTOCOL_VERSIONS ) + ) + )->toArray(); + } + + /** + * Add session header to the REST response. + * + * Uses a static flag to prevent multiple filters from being added + * if this method is called multiple times during a single request + * (e.g., during batch JSON-RPC processing). + * + * @param string $session_id The session ID to add to the response header. + * + * @return void + */ + private function add_session_header_to_response( string $session_id ): void { + static $current_session_id = null; + + // Only add filter once per request, or if session ID changes + if ( null !== $current_session_id && $current_session_id === $session_id ) { + return; + } + + add_filter( + 'rest_post_dispatch', + static function ( $response ) use ( $session_id ) { + if ( $response instanceof \WP_REST_Response ) { + $response->header( 'Mcp-Session-Id', $session_id ); + } + + return $response; + } + ); + + $current_session_id = $session_id; + } + + /** + * Handle GET requests (SSE streaming). + * + * @return \WP_REST_Response SSE response. + */ + private function handle_sse_request(): \WP_REST_Response { + // SSE streaming not yet implemented - return HTTP 405 with no body + return new \WP_REST_Response( null, 405 ); + } + + /** + * Handle DELETE requests (session termination). + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return \WP_REST_Response Termination response. + */ + private function handle_session_termination( HttpRequestContext $context ): \WP_REST_Response { + $result = HttpSessionValidator::terminate_session_with_error_handler( $context, $this->transport_context->error_handler ); + + if ( true !== $result ) { + $http_status = McpErrorFactory::get_http_status_for_error( $result ); + + return new \WP_REST_Response( $result, $http_status ); + } + + return new \WP_REST_Response( null, 200 ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpSessionValidator.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpSessionValidator.php new file mode 100644 index 0000000000..600081ffb7 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/HttpSessionValidator.php @@ -0,0 +1,169 @@ +session_id; + if ( ! $session_id ) { + return McpErrorFactory::invalid_request( null, 'Missing Mcp-Session-Id header' )->toArray(); + } + + // Check user authentication + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return McpErrorFactory::unauthorized( null, 'User not authenticated' )->toArray(); + } + + // Validate session using SessionManager + if ( ! SessionManager::validate_session( $user_id, $session_id, $error_handler ) ) { + return McpErrorFactory::session_not_found( null, 'Invalid or expired session' )->toArray(); + } + + return true; + } + + /** + * Validate session header presence in HTTP request. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return string|array Session ID on success, error array on failure. + */ + public static function validate_session_header( HttpRequestContext $context ) { + $session_id = $context->session_id; + + if ( ! $session_id ) { + return McpErrorFactory::invalid_request( null, 'Missing Mcp-Session-Id header' )->toArray(); + } + + return $session_id; + } + + /** + * Create a new session for the current user with HTTP context awareness. + * + * Validates user authentication and creates session, providing better error + * context than direct SessionManager calls. + * + * @param array $params The client parameters from initialize request. + * + * @return string|array Session ID on success, error array on failure. + */ + public static function create_session( array $params = array() ) { + return self::create_session_with_error_handler( $params, null ); + } + + /** + * Create a session and report storage failures to an error handler. + * + * @since 0.6.0 + * @internal + * + * @param array $params The client parameters from initialize request. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. + * + * @return string|array Session ID on success, error array on failure. + */ + public static function create_session_with_error_handler( array $params, ?McpErrorHandlerInterface $error_handler ) { + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return McpErrorFactory::unauthorized( null, 'User authentication required for session creation' )->toArray(); + } + + $session_id = SessionManager::create_session( $user_id, $params, $error_handler ); + + if ( ! $session_id ) { + return McpErrorFactory::internal_error( null, 'Failed to create session' )->toArray(); + } + + return $session_id; + } + + /** + * Terminate a session with full HTTP context validation. + * + * Performs complete validation workflow for session termination including + * header validation, user authentication, and session cleanup. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * + * @return array|true Returns true on success, error array on failure. + */ + public static function terminate_session( HttpRequestContext $context ) { + return self::terminate_session_with_error_handler( $context, null ); + } + + /** + * Terminate a session and report storage failures to an error handler. + * + * @since 0.6.0 + * @internal + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. + * + * @return array|true Returns true on success, error array on failure. + */ + public static function terminate_session_with_error_handler( HttpRequestContext $context, ?McpErrorHandlerInterface $error_handler ) { + // Validate session header + $session_id = $context->session_id; + if ( ! $session_id ) { + return McpErrorFactory::invalid_request( null, 'Missing Mcp-Session-Id header' )->toArray(); + } + + // Validate user authentication + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return McpErrorFactory::unauthorized( null, 'User not authenticated' )->toArray(); + } + + // Terminate the session + SessionManager::delete_session( $user_id, $session_id, $error_handler ); + + return true; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/JsonRpcResponseBuilder.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/JsonRpcResponseBuilder.php new file mode 100644 index 0000000000..b1fa7e69f5 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/JsonRpcResponseBuilder.php @@ -0,0 +1,110 @@ + McpConstants::JSONRPC_VERSION, + 'id' => $request_id, + // Make sure the result is an object (not an array) + 'result' => (object) $result, + ); + } + + /** + * Create a JSON-RPC 2.0 error response. + * + * @param mixed $request_id The request ID from the original JSON-RPC request (string, number, or null). + * @param array $error The error array with 'code', 'message', and optional 'data'. + * + * @return array The formatted JSON-RPC error response. + */ + public static function create_error_response( $request_id, array $error ): array { + return array( + 'jsonrpc' => McpConstants::JSONRPC_VERSION, + 'id' => $request_id, + 'error' => $error, + ); + } + + /** + * Process multiple MCP messages and format the response correctly. + * + * Handles both batch requests (array of messages) and single requests, + * returning the appropriate response format per JSON-RPC 2.0 specification. + * + * @param array $messages Array of JSON-RPC messages to process. + * @param bool $is_batch_request Whether the original request was a batch. + * @param callable $processor Callback function to process each individual message. + * Should accept (array $message) and return array $response. + * + * @return array|null The formatted response (array for batch, single response for non-batch). + */ + public static function process_messages( array $messages, bool $is_batch_request, callable $processor ): ?array { + $results = array(); + + foreach ( $messages as $message ) { + $response = call_user_func( $processor, $message ); + if ( null === $response ) { + continue; + } + + $results[] = $response; + } + + // Return response format based on original request format (JSON-RPC 2.0 spec) + // If the request was a batch, response MUST be an array, even if only one result + return $is_batch_request ? $results : ( $results[0] ?? null ); + } + + /** + * Normalize request body to an array of messages. + * + * Converts single messages to an array for uniform processing. + * + * @param mixed $body The decoded request body. + * + * @return array Array of messages for processing. + */ + public static function normalize_messages( $body ): array { + return self::is_batch_request( $body ) ? $body : array( $body ); + } + + /** + * Determine if a request body represents a batch request. + * + * Per JSON-RPC 2.0 specification, a batch request is an array with at least one element. + * + * @param mixed $body The decoded request body. + * + * @return bool True if this is a batch request. + */ + public static function is_batch_request( $body ): bool { + return is_array( $body ) && isset( $body[0] ); + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportContext.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportContext.php new file mode 100644 index 0000000000..a30bd669b9 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportContext.php @@ -0,0 +1,207 @@ + + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- False positive: sniff mistakes array() commas for multi-const commas (only handles short syntax). + private const REQUIRED_KEYS = array( + 'mcp_server', + 'initialize_handler', + 'tools_handler', + 'resources_handler', + 'prompts_handler', + 'system_handler', + 'observability_handler', + ); + + /** + * Optional property keys for the constructor array. + * + * @var list + */ + // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- False positive: sniff mistakes array() commas for multi-const commas (only handles short syntax). + private const OPTIONAL_KEYS = array( + 'request_router', + 'transport_permission_callback', + 'error_handler', + ); + + /** + * The MCP server instance. + * + * @var \WP\MCP\Core\McpServer + */ + public McpServer $mcp_server; + + /** + * The initialize handler. + * + * @var \WP\MCP\Handlers\Initialize\InitializeHandler + */ + public InitializeHandler $initialize_handler; + + /** + * The tools handler. + * + * @var \WP\MCP\Handlers\Tools\ToolsHandler + */ + public ToolsHandler $tools_handler; + + /** + * The resources handler. + * + * @var \WP\MCP\Handlers\Resources\ResourcesHandler + */ + public ResourcesHandler $resources_handler; + + /** + * The prompts handler. + * + * @var \WP\MCP\Handlers\Prompts\PromptsHandler + */ + public PromptsHandler $prompts_handler; + + /** + * The system handler. + * + * @var \WP\MCP\Handlers\System\SystemHandler + */ + public SystemHandler $system_handler; + + /** + * The observability handler instance. + * + * @var \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface + */ + public McpObservabilityHandlerInterface $observability_handler; + + /** + * The error handler instance. + * + * @var \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface + */ + public McpErrorHandlerInterface $error_handler; + + /** + * The request router service. + */ + public RequestRouter $request_router; + + /** + * Optional custom permission callback for transport-level authentication. + * + * @var callable|callable-string|null + */ + public $transport_permission_callback; + + /** + * Initialize the transport context. + * + * @param array{ + * mcp_server: \WP\MCP\Core\McpServer, + * initialize_handler: \WP\MCP\Handlers\Initialize\InitializeHandler, + * tools_handler: \WP\MCP\Handlers\Tools\ToolsHandler, + * resources_handler: \WP\MCP\Handlers\Resources\ResourcesHandler, + * prompts_handler: \WP\MCP\Handlers\Prompts\PromptsHandler, + * system_handler: \WP\MCP\Handlers\System\SystemHandler, + * observability_handler: \WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface, + * request_router?: \WP\MCP\Transport\Infrastructure\RequestRouter, + * transport_permission_callback?: callable|null, + * error_handler?: \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface + * } $properties Properties to set on the context. + * Note: request_router is optional and will be auto-created if not provided. + * + * @throws \InvalidArgumentException If required keys are missing or unknown keys are present. + * + * @since 0.5.0 + */ + public function __construct( array $properties ) { + $this->validate_properties( $properties ); + + // Assign required properties. + $this->mcp_server = $properties['mcp_server']; + $this->initialize_handler = $properties['initialize_handler']; + $this->tools_handler = $properties['tools_handler']; + $this->resources_handler = $properties['resources_handler']; + $this->prompts_handler = $properties['prompts_handler']; + $this->system_handler = $properties['system_handler']; + $this->observability_handler = $properties['observability_handler']; + + // Assign optional properties (error_handler defaults to the server's handler). + $this->error_handler = $properties['error_handler'] ?? $properties['mcp_server']->get_error_handler(); + + $this->transport_permission_callback = $properties['transport_permission_callback'] ?? null; + + // Create request_router if not provided. + $this->request_router = $properties['request_router'] ?? new RequestRouter( $this ); + } + + /** + * Validate that the properties array contains all required keys and no unknown keys. + * + * @param array $properties Properties to validate. + * + * @throws \InvalidArgumentException If required keys are missing or unknown keys are present. + * + * @since 0.5.0 + */ + private function validate_properties( array $properties ): void { + $provided_keys = array_keys( $properties ); + $allowed_keys = array_merge( self::REQUIRED_KEYS, self::OPTIONAL_KEYS ); + + // Check for unknown keys. + $unknown_keys = array_diff( $provided_keys, $allowed_keys ); + if ( ! empty( $unknown_keys ) ) { + throw new \InvalidArgumentException( + sprintf( + 'Unknown properties provided to McpTransportContext: %1$s. Allowed properties: %2$s.', + esc_html( implode( ', ', $unknown_keys ) ), + esc_html( implode( ', ', $allowed_keys ) ) + ) + ); + } + + // Check for missing required keys. + $missing_keys = array_diff( self::REQUIRED_KEYS, $provided_keys ); + if ( ! empty( $missing_keys ) ) { + throw new \InvalidArgumentException( + sprintf( + 'Missing required properties for McpTransportContext: %s.', + esc_html( implode( ', ', $missing_keys ) ) + ) + ); + } + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportHelperTrait.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportHelperTrait.php new file mode 100644 index 0000000000..857bb2acda --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/McpTransportHelperTrait.php @@ -0,0 +1,39 @@ +context = $context; + } + + /** + * Route a request to the appropriate handler. + * + * @param string $method The MCP method name. + * @param array $params The request parameters. + * @param mixed $request_id The request ID (for JSON-RPC) - string, number, or null. + * @param string $transport_name Transport name for observability. + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management. + * + * @return array + */ + public function route_request( string $method, array $params, $request_id = 0, string $transport_name = 'unknown', ?HttpRequestContext $http_context = null ): array { + // Track request start time. + $start_time = microtime( true ); + + $new_session_id = null; + $component_tags = $this->resolve_component_observability_context( $method, $params ); + + // Common tags for all metrics. + $common_tags = array( + 'method' => $method, + 'transport' => $transport_name, + 'server_id' => $this->context->mcp_server->get_server_id(), + 'params' => $this->sanitize_params_for_logging( $params ), + 'request_id' => $request_id, + 'session_id' => $http_context ? $http_context->session_id : null, + ); + + $handlers = array( + 'initialize' => function () use ( $params, $request_id, $http_context, &$new_session_id ) { + return $this->handle_initialize_with_session( $params, $request_id, $http_context, $new_session_id ); + }, + 'ping' => fn() => $this->context->system_handler->ping(), + 'tools/list' => fn() => $this->context->tools_handler->list_tools(), + 'tools/list/all' => fn() => $this->context->tools_handler->list_all_tools(), + 'tools/call' => fn() => $this->context->tools_handler->call_tool( $params, $request_id ), + 'resources/list' => fn() => $this->context->resources_handler->list_resources(), + 'resources/templates/list' => fn() => $this->context->resources_handler->list_resource_templates(), + 'resources/read' => fn() => $this->context->resources_handler->read_resource( $params, $request_id ), + 'prompts/list' => fn() => $this->context->prompts_handler->list_prompts(), + 'prompts/get' => fn() => $this->context->prompts_handler->get_prompt( $params, $request_id ), + ); + + try { + $handler_result = isset( $handlers[ $method ] ) ? $handlers[ $method ]() : $this->create_method_not_found_error( $method, $request_id ); + + // Calculate request duration. + $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds. + + // Handle DTO results from migrated handlers. + // DTOs are converted to arrays at the serialization boundary (here). + if ( $handler_result instanceof JSONRPCErrorResponse ) { + // Normalize to transport-level shape: only the JSON-RPC error object. + // The JSON-RPC envelope is created by the transport boundary. + $result = array( 'error' => $handler_result->getError()->toArray() ); + $tags = array_merge( $common_tags, $component_tags, array( 'status' => 'error' ) ); + $tags['error_code'] = $handler_result->getError()->getCode(); + $tags['failure_reason'] = $handler_result->getError()->getMessage(); + $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); + + return $result; + } + + if ( $handler_result instanceof AbstractDataTransferObject ) { + // Success DTO (ListToolsResult, CallToolResult, etc.) - convert to array. + // Note: If a future schema version ever returns nested DTO objects inside `toArray()`, + // we may need to add a deep normalizer at this boundary (before JSON serialization) + // to prevent placeholder `{}` objects in client output. + $raw_result = $handler_result->toArray(); + $result = $raw_result; + + if ( null !== $new_session_id ) { + $component_tags['new_session_id'] = $new_session_id; + $result['_session_id'] = $new_session_id; + } + + $status = 'success'; + if ( $handler_result instanceof CallToolResult && true === $handler_result->getIsError() ) { + $status = 'error'; + + if ( ! isset( $component_tags['failure_reason'] ) ) { + $content = $handler_result->getContent(); + if ( isset( $content[0] ) && $content[0] instanceof TextContent ) { + $component_tags['failure_reason'] = $content[0]->getText(); + } + } + } + + $tags = array_merge( $common_tags, $component_tags, array( 'status' => $status ) ); + $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); + + return $result; + } + + // Handlers should only return schema DTOs. + $actual_type = is_object( $handler_result ) ? get_class( $handler_result ) : gettype( $handler_result ); + $this->context->error_handler->log( + sprintf( 'Handler for method "%s" returned unexpected type: %s', $method, $actual_type ), + array( + 'method' => $method, + 'actual_type' => $actual_type, + ) + ); + $unexpected_error = McpErrorFactory::internal_error( $request_id, 'Handler returned invalid response type.' ); + $result = array( 'error' => $unexpected_error->getError()->toArray() ); + $tags = array_merge( $common_tags, $component_tags, array( 'status' => 'error' ) ); + $tags['error_code'] = $unexpected_error->getError()->getCode(); + $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); + + return $result; + } catch ( \Throwable $exception ) { + // Calculate request duration. + $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds. + + // Track exception with categorization. + $tags = array_merge( + $common_tags, + $component_tags, + array( + 'status' => 'error', + 'error_type' => get_class( $exception ), + 'error_category' => $this->categorize_error( $exception ), + ) + ); + $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); + + // Create error response from exception. + $unexpected_error = McpErrorFactory::internal_error( $request_id, 'Handler error occurred' ); + + return array( 'error' => $unexpected_error->getError()->toArray() ); + } + } + + /** + * Resolve per-component observability tags for a request. + * + * This replaces legacy approaches that derived tags from DTO `_meta`. + * + * @param string $method MCP method name. + * @param array $params Request parameters (root or nested under `params`). + * + * @return array + */ + private function resolve_component_observability_context( string $method, array $params ): array { + $request_params = $params['params'] ?? $params; + + if ( ! is_array( $request_params ) ) { + $request_params = array(); + } + + switch ( $method ) { + case 'tools/call': + $tool_name = $request_params['name'] ?? null; + $tool_name = is_string( $tool_name ) ? trim( $tool_name ) : null; + + if ( null === $tool_name || '' === $tool_name ) { + return array(); + } + + $mcp_tool = $this->context->mcp_server->get_mcp_tool( $tool_name ); + if ( $mcp_tool ) { + return $mcp_tool->get_observability_context(); + } + + return array( + 'component_type' => 'tool', + 'tool_name' => $tool_name, + ); + + case 'prompts/get': + $prompt_name = $request_params['name'] ?? null; + $prompt_name = is_string( $prompt_name ) ? trim( $prompt_name ) : null; + + if ( null === $prompt_name || '' === $prompt_name ) { + return array(); + } + + $mcp_prompt = $this->context->mcp_server->get_mcp_prompt( $prompt_name ); + if ( $mcp_prompt ) { + return $mcp_prompt->get_observability_context(); + } + + return array( + 'component_type' => 'prompt', + 'prompt_name' => $prompt_name, + ); + + case 'resources/read': + $resource_uri = $request_params['uri'] ?? null; + $resource_uri = is_string( $resource_uri ) ? trim( $resource_uri ) : null; + + if ( null === $resource_uri || '' === $resource_uri ) { + return array(); + } + + $mcp_resource = $this->context->mcp_server->get_mcp_resource( $resource_uri ); + if ( $mcp_resource ) { + return $mcp_resource->get_observability_context(); + } + + return array( + 'component_type' => 'resource', + 'resource_uri' => $resource_uri, + ); + } + + return array(); + } + + /** + * Sanitize request params for logging to remove sensitive data and limit size. + * + * @param array $params The request parameters to sanitize. + * + * @return array Sanitized parameters safe for logging. + */ + private function sanitize_params_for_logging( array $params ): array { + // Return early for empty parameters. + if ( empty( $params ) ) { + return array(); + } + + $sanitized = array(); + + // Extract only safe, useful fields for observability + $safe_fields = array( 'name', 'protocolVersion', 'uri' ); + + foreach ( $safe_fields as $field ) { + if ( ! isset( $params[ $field ] ) || ! is_scalar( $params[ $field ] ) ) { + continue; + } + + $sanitized[ $field ] = $params[ $field ]; + } + + // Add clientInfo name if available (useful for debugging) + if ( isset( $params['clientInfo']['name'] ) ) { + $sanitized['client_name'] = $params['clientInfo']['name']; + } + + // Add arguments count for tool calls (but not the actual arguments to avoid logging sensitive data). + // Also filter out sensitive-looking keys to avoid leaking secret names. + if ( isset( $params['arguments'] ) && is_array( $params['arguments'] ) ) { + $sanitized['arguments_count'] = count( $params['arguments'] ); + + // Filter argument keys to exclude sensitive-looking ones. + $safe_keys = array(); + foreach ( array_keys( $params['arguments'] ) as $arg_key ) { + // @todo Replace this with a less-coupled way to access `McpObservabilityHelperTrait:is_sensitive_key()`. + if ( ErrorLogMcpObservabilityHandler::is_sensitive_key( (string) $arg_key ) ) { + $safe_keys[] = '[REDACTED]'; + } else { + $safe_keys[] = $arg_key; + } + } + $sanitized['arguments_keys'] = $safe_keys; + } + + return $sanitized; + } + + /** + * Handle initialize requests with session management. + * + * Converts InitializeResult DTO to array and adds session management. + * + * @param array $params The request parameters. + * @param mixed $request_id The request ID. + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management. + * @param string|null $new_session_id Newly created session id, if any. + * + * @return \WP\McpSchema\Common\AbstractDataTransferObject + */ + private function handle_initialize_with_session( array $params, $request_id, ?HttpRequestContext $http_context, ?string &$new_session_id = null ): AbstractDataTransferObject { + // Extract client protocol version from params, defaulting to empty string if missing. + $client_version = isset( $params['protocolVersion'] ) && is_string( $params['protocolVersion'] ) ? $params['protocolVersion'] : ''; + + // Get the initialize response from the handler (returns InitializeResult DTO). + $init_result = $this->context->initialize_handler->handle( $client_version ); + + // Handle session creation if HTTP context is provided. + // InitializeResult DTO never has errors - errors would be thrown as exceptions. + if ( $http_context && ! $http_context->session_id ) { + $session_result = HttpSessionValidator::create_session_with_error_handler( $params, $this->context->error_handler ); + + if ( is_array( $session_result ) ) { + $error = $session_result['error'] ?? array(); + + return McpErrorFactory::create_error_response( + $request_id, + isset( $error['code'] ) ? (int) $error['code'] : McpErrorFactory::INTERNAL_ERROR, + (string) ( $error['message'] ?? __( 'Failed to create session', 'mcp-adapter' ) ), + $error['data'] ?? null + ); + } + + $new_session_id = $session_result; + } + + return $init_result; + } + + /** + * Create a method not found error with generic format. + * + * @param string $method The method that was not found. + * @param mixed $request_id The request ID. + * + * @return \WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse + */ + private function create_method_not_found_error( string $method, $request_id ): JSONRPCErrorResponse { + return McpErrorFactory::method_not_found( $request_id, $method ); + } + + /** + * Categorize an exception into a general error category. + * + * @param \Throwable $exception The exception to categorize. + * + * @return string + */ + private function categorize_error( \Throwable $exception ): string { + $error_categories = array( + \ArgumentCountError::class => 'arguments', + \TypeError::class => 'type', + \InvalidArgumentException::class => 'validation', + \LogicException::class => 'logic', + \RuntimeException::class => 'execution', + \Error::class => 'system', + ); + + foreach ( $error_categories as $class => $category ) { + if ( $exception instanceof $class ) { + return $category; + } + } + + return 'unknown'; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/SessionManager.php b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/SessionManager.php new file mode 100644 index 0000000000..071c00b6f1 --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/includes/Transport/Infrastructure/SessionManager.php @@ -0,0 +1,459 @@ + $session ) { + if ( $session['last_activity'] + $config['inactivity_timeout'] >= $now ) { + continue; + } + + unset( $sessions[ $stored_session_id ] ); + } + + if ( count( $sessions ) >= $config['max_sessions'] ) { + uasort( + $sessions, + static function ( $a, $b ) { + return $a['created_at'] <=> $b['created_at']; + } + ); + + array_shift( $sessions ); + } + + $sessions[ $session_id ] = array( + 'created_at' => $now, + 'last_activity' => $now, + 'client_params' => $params, + ); + + return $sessions; + }, + $error_handler + ); + + if ( ! $created ) { + return false; + } + + return $session_id; + } + + /** + * Apply a session mutation without overwriting an established session map. + * + * WordPress ignores an empty $prev_value. Concurrent first connections may + * therefore still overwrite each other, but subsequent writes retry when the + * previously read non-empty map has changed. + * + * @since 0.6.0 + * + * @param int $user_id The user ID. + * @param callable $mutation Receives the latest sessions and returns the updated sessions. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler. + * @return bool True when the mutation was stored, false after repeated conflicts. + */ + private static function mutate_sessions( int $user_id, callable $mutation, ?McpErrorHandlerInterface $error_handler = null ): bool { + for ( $attempt = 0; $attempt < self::MAX_UPDATE_ATTEMPTS; ++$attempt ) { + wp_cache_delete( $user_id, 'user_meta' ); + $previous_sessions = self::get_all_user_sessions( $user_id ); + $updated_sessions = $mutation( $previous_sessions ); + + if ( $updated_sessions === $previous_sessions ) { + return true; + } + + $updated = update_user_meta( $user_id, self::session_meta_key(), $updated_sessions, $previous_sessions ); + if ( false !== $updated ) { + return true; + } + } + + $error_handler = $error_handler ?? new ErrorLogMcpErrorHandler(); + $error_handler->log( + 'Failed to persist MCP sessions after exhausting update retries.', + array( + 'component' => self::class, + 'method' => 'mutate_sessions', + 'user_id' => $user_id, + 'attempts' => self::MAX_UPDATE_ATTEMPTS, + ) + ); + + return false; + } + + /** + * Cleanup inactive sessions for a user + * + * @param int $user_id The user ID. + * + * @return int Number of sessions removed. + */ + public static function cleanup_expired_sessions( int $user_id ): int { + if ( ! $user_id ) { + return 0; + } + + $now = time(); + $removed = 0; + $config = self::get_config(); + $inactivity_timeout = $config['inactivity_timeout']; + + $stored = self::mutate_sessions( + $user_id, + static function ( array $sessions ) use ( $inactivity_timeout, $now, &$removed ): array { + $removed = 0; + + foreach ( $sessions as $session_id => $session ) { + if ( $session['last_activity'] + $inactivity_timeout >= $now ) { + continue; + } + + unset( $sessions[ $session_id ] ); + ++$removed; + } + + return $sessions; + } + ); + + return $stored ? $removed : 0; + } + + /** + * Get all sessions for a user on the current site. + * + * @param int $user_id The user ID. + * + * @return array Array of sessions. + */ + public static function get_all_user_sessions( int $user_id ): array { + if ( ! $user_id ) { + return array(); + } + + $sessions = get_user_meta( $user_id, self::session_meta_key(), true ); + + if ( ! is_array( $sessions ) ) { + return array(); + } + + return $sessions; + } + + /** + * Get configuration values. + * + * @return array{max_sessions: int, inactivity_timeout: int, activity_update_interval: int} Configuration array. + */ + private static function get_config(): array { + /** + * Filters the maximum number of MCP sessions allowed per user on the current site. + * + * When a user exceeds this limit on the current site, the oldest inactive + * session is automatically removed to make room for new sessions. + * + * @since 0.3.0 + * + * @param int $max_sessions Maximum sessions per user on the current site. Default 32. + */ + $max_sessions = (int) apply_filters( 'mcp_adapter_session_max_per_user', self::DEFAULT_MAX_SESSIONS ); + + /** + * Filters the session inactivity timeout in seconds. + * + * Sessions that have been inactive longer than this duration are + * considered expired and may be cleaned up automatically. + * + * @since 0.3.0 + * + * @param int $timeout Inactivity timeout in seconds. Default DAY_IN_SECONDS (86400 / 24 hours). + */ + $inactivity_timeout = (int) apply_filters( 'mcp_adapter_session_inactivity_timeout', self::DEFAULT_INACTIVITY_TIMEOUT ); + + /** + * Filters the minimum interval between session last_activity writes. + * + * To reduce write amplification, the session manager only updates + * `last_activity` if at least this many seconds have elapsed since + * the last write. + * + * @since 0.5.0 + * + * @param int $interval Minimum seconds between writes. Default 60. + */ + $activity_update_interval = (int) apply_filters( 'mcp_adapter_session_activity_update_interval', self::DEFAULT_ACTIVITY_UPDATE_INTERVAL ); + + // Clamp: interval must be less than inactivity timeout to prevent + // sessions from expiring despite active use. + if ( $activity_update_interval >= $inactivity_timeout ) { + $activity_update_interval = (int) ( $inactivity_timeout / 2 ); + } + + return array( + 'max_sessions' => $max_sessions, + 'inactivity_timeout' => $inactivity_timeout, + 'activity_update_interval' => max( 0, $activity_update_interval ), + ); + } + + /** + * Get a specific session for a user + * + * @param int $user_id The user ID. + * @param string $session_id The session ID. + * + * @return array|\WP_Error|false Session data on success, WP_Error on invalid input, false if not found or inactive. + */ + public static function get_session( int $user_id, string $session_id ) { + if ( ! $user_id || ! $session_id ) { + return new WP_Error( 'mcp_session_invalid_input', 'Invalid user ID or session ID.' ); + } + + $sessions = self::get_all_user_sessions( $user_id ); + + if ( ! isset( $sessions[ $session_id ] ) ) { + return false; + } + + $session = $sessions[ $session_id ]; + + // Check inactivity timeout + $config = self::get_config(); + $inactivity_timeout = $config['inactivity_timeout']; + if ( $session['last_activity'] + $inactivity_timeout < time() ) { + self::clear_session( $user_id, $session_id ); + + return false; + } + + return $session; + } + + /** + * Clear an inactive session (internal cleanup). + * + * @param int $user_id The user ID. + * @param string $session_id The session ID to clear. + * + * @return void + */ + private static function clear_session( int $user_id, string $session_id ): void { + self::mutate_sessions( + $user_id, + static function ( array $sessions ) use ( $session_id ): array { + unset( $sessions[ $session_id ] ); + + return $sessions; + } + ); + } + + /** + * Validate a session and update last activity + * + * @since 0.6.0 Added the optional error handler. + * + * @param int $user_id The user ID. + * @param string $session_id The session ID. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_session( int $user_id, string $session_id, ?McpErrorHandlerInterface $error_handler = null ): bool { + if ( ! $user_id || ! $session_id ) { + return false; + } + + $config = self::get_config(); + $now = time(); + $is_valid = false; + + $stored = self::mutate_sessions( + $user_id, + static function ( array $sessions ) use ( $config, $now, $session_id, &$is_valid ): array { + if ( ! isset( $sessions[ $session_id ] ) ) { + $is_valid = false; + return $sessions; + } + + if ( $sessions[ $session_id ]['last_activity'] + $config['inactivity_timeout'] < $now ) { + $is_valid = false; + unset( $sessions[ $session_id ] ); + + return $sessions; + } + + $is_valid = true; + if ( $now - $sessions[ $session_id ]['last_activity'] >= $config['activity_update_interval'] ) { + $sessions[ $session_id ]['last_activity'] = $now; + } + + return $sessions; + }, + $error_handler + ); + + return $stored && $is_valid; + } + + /** + * Delete a specific session + * + * @since 0.6.0 Added the optional error handler. + * + * @param int $user_id The user ID. + * @param string $session_id The session ID. + * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Error handler for reporting storage failures. Defaults to the standard error-log handler. + * + * @return bool True on success, false on failure. + */ + public static function delete_session( int $user_id, string $session_id, ?McpErrorHandlerInterface $error_handler = null ): bool { + if ( ! $user_id || ! $session_id ) { + return false; + } + + $session_found = false; + $stored = self::mutate_sessions( + $user_id, + static function ( array $sessions ) use ( $session_id, &$session_found ): array { + if ( ! isset( $sessions[ $session_id ] ) ) { + $session_found = false; + return $sessions; + } + + $session_found = true; + unset( $sessions[ $session_id ] ); + + return $sessions; + }, + $error_handler + ); + + return $stored && $session_found; + } +} diff --git a/lib/vendor/wordpress/mcp-adapter/mcp-adapter.php b/lib/vendor/wordpress/mcp-adapter/mcp-adapter.php new file mode 100644 index 0000000000..06e421f8de --- /dev/null +++ b/lib/vendor/wordpress/mcp-adapter/mcp-adapter.php @@ -0,0 +1,58 @@ + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/lib/vendor/wordpress/php-mcp-schema/phpstan.neon b/lib/vendor/wordpress/php-mcp-schema/phpstan.neon new file mode 100644 index 0000000000..a3df6471fc --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + phpVersion: 70400 + level: max + paths: + - src \ No newline at end of file diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/BooleanSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/BooleanSchema.php new file mode 100644 index 0000000000..0144d853e7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/BooleanSchema.php @@ -0,0 +1,146 @@ +type = self::TYPE; + $this->title = $title; + $this->description = $description; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'boolean', + * title?: string|null, + * description?: string|null, + * default?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asBoolOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'boolean' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return bool|null + */ + public function getDefault(): ?bool + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequest.php new file mode 100644 index 0000000000..4b602e2502 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequest.php @@ -0,0 +1,106 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'elicitation/create', + * params: array|\WP\McpSchema\Client\Elicitation\Union\ElicitRequestParamsInterface + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Client\Elicitation\Union\ElicitRequestParamsInterface $params */ + $params = is_array($data['params']) + ? ElicitRequestParamsFactory::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Client\Elicitation\Union\ElicitRequestParamsInterface + */ + public function getTypedParams(): ElicitRequestParamsInterface + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParams.php new file mode 100644 index 0000000000..9801274baa --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParams.php @@ -0,0 +1,162 @@ +mode = self::MODE; + $this->message = $message; + $this->requestedSchema = $requestedSchema; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * task?: array|\WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null, + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * mode?: 'form'|null, + * message: string, + * requestedSchema: array|\WP\McpSchema\Client\Elicitation\DTO\ElicitRequestFormParamsRequestedSchema + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['message', 'requestedSchema']); + + /** @var \WP\McpSchema\Client\Elicitation\DTO\ElicitRequestFormParamsRequestedSchema $requestedSchema */ + $requestedSchema = is_array($data['requestedSchema']) + ? ElicitRequestFormParamsRequestedSchema::fromArray(self::asArray($data['requestedSchema'])) + : $data['requestedSchema']; + + /** @var \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task */ + $task = isset($data['task']) + ? (is_array($data['task']) + ? TaskMetadata::fromArray(self::asArray($data['task'])) + : $data['task']) + : null; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['message']), + $requestedSchema, + $task, + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->mode !== null) { + $result['mode'] = $this->mode; + } + $result['message'] = $this->message; + $result['requestedSchema'] = $this->requestedSchema->toArray(); + + return $result; + } + + /** + * @return 'form'|null + */ + public function getMode(): ?string + { + return $this->mode; + } + + /** + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * @return \WP\McpSchema\Client\Elicitation\DTO\ElicitRequestFormParamsRequestedSchema + */ + public function getRequestedSchema(): ElicitRequestFormParamsRequestedSchema + { + return $this->requestedSchema; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParamsRequestedSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParamsRequestedSchema.php new file mode 100644 index 0000000000..555e214ef1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestFormParamsRequestedSchema.php @@ -0,0 +1,114 @@ +|null + */ + protected ?array $required; + + /** + * @param string|null $schema + * @param array|null $required + */ + public function __construct( + ?string $schema = null, + ?array $required = null + ) { + $this->type = self::TYPE; + $this->schema = $schema; + $this->required = $required; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * '$schema'?: string|null, + * type: 'object', + * required?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['$schema'] ?? null), + self::asStringArrayOrNull($data['required'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->schema !== null) { + $result['$schema'] = $this->schema; + } + $result['type'] = $this->type; + if ($this->required !== null) { + $result['required'] = $this->required; + } + + return $result; + } + + /** + * @return string|null + */ + public function getSchema(): ?string + { + return $this->schema; + } + + /** + * @return 'object' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return array|null + */ + public function getRequired(): ?array + { + return $this->required; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestURLParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestURLParams.php new file mode 100644 index 0000000000..3e60dce325 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitRequestURLParams.php @@ -0,0 +1,178 @@ +mode = self::MODE; + $this->message = $message; + $this->elicitationId = $elicitationId; + $this->url = $url; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * task?: array|\WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null, + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * mode: 'url', + * message: string, + * elicitationId: string, + * url: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['message', 'elicitationId', 'url']); + + /** @var \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task */ + $task = isset($data['task']) + ? (is_array($data['task']) + ? TaskMetadata::fromArray(self::asArray($data['task'])) + : $data['task']) + : null; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['message']), + self::asString($data['elicitationId']), + self::asString($data['url']), + $task, + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['mode'] = $this->mode; + $result['message'] = $this->message; + $result['elicitationId'] = $this->elicitationId; + $result['url'] = $this->url; + + return $result; + } + + /** + * @return 'url' + */ + public function getMode(): string + { + return $this->mode; + } + + /** + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * @return string + */ + public function getElicitationId(): string + { + return $this->elicitationId; + } + + /** + * @return string + */ + public function getUrl(): string + { + return $this->url; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitResult.php new file mode 100644 index 0000000000..34204bd96c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitResult.php @@ -0,0 +1,129 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'action', 'content']; + + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly decline the action + * - "cancel": User dismissed without making an explicit choice + * + * @since 2025-06-18 + * + * @var 'accept'|'decline'|'cancel' + */ + protected string $action; + + /** + * The submitted form data, only present when action is "accept" and mode was "form". + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + * + * @since 2025-06-18 + * + * @var array|null + */ + protected ?array $content; + + /** + * @param 'accept'|'decline'|'cancel' $action @since 2025-06-18 + * @param array|null $_meta @since 2025-06-18 + * @param array|null $content @since 2025-06-18 + * @param array|null $additionalProperties + */ + public function __construct( + string $action, + ?array $_meta = null, + ?array $content = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->action = $action; + $this->content = $content; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * action: 'accept'|'decline'|'cancel', + * content?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['action']); + + /** @var 'accept'|'decline'|'cancel' $action */ + $action = self::asString($data['action']); + + return new self( + $action, + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringMapOrNull($data['content'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['action'] = $this->action; + if ($this->content !== null) { + $result['content'] = $this->content; + } + + return $result; + } + + /** + * @return 'accept'|'decline'|'cancel' + */ + public function getAction(): string + { + return $this->action; + } + + /** + * @return array|null + */ + public function getContent(): ?array + { + return $this->content; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotification.php new file mode 100644 index 0000000000..8385f6b24d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotification.php @@ -0,0 +1,96 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/elicitation/complete', + * params: array|\WP\McpSchema\Client\Elicitation\DTO\ElicitationCompleteNotificationParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Client\Elicitation\DTO\ElicitationCompleteNotificationParams $params */ + $params = is_array($data['params']) + ? ElicitationCompleteNotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Client\Elicitation\DTO\ElicitationCompleteNotificationParams + */ + public function getTypedParams(): ElicitationCompleteNotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotificationParams.php new file mode 100644 index 0000000000..9c1fab30b9 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/ElicitationCompleteNotificationParams.php @@ -0,0 +1,74 @@ +elicitationId = $elicitationId; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * elicitationId: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['elicitationId']); + + return new self( + self::asString($data['elicitationId']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['elicitationId'] = $this->elicitationId; + + return $result; + } + + /** + * @return string + */ + public function getElicitationId(): string + { + return $this->elicitationId; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/LegacyTitledEnumSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/LegacyTitledEnumSchema.php new file mode 100644 index 0000000000..e4887b0c64 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/LegacyTitledEnumSchema.php @@ -0,0 +1,198 @@ + + */ + protected array $enum; + + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + * + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $enumNames; + + /** + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $default; + + /** + * @param array $enum @since 2025-11-25 + * @param string|null $title @since 2025-11-25 + * @param string|null $description @since 2025-11-25 + * @param array|null $enumNames @since 2025-11-25 + * @param string|null $default @since 2025-11-25 + */ + public function __construct( + array $enum, + ?string $title = null, + ?string $description = null, + ?array $enumNames = null, + ?string $default = null + ) { + $this->type = self::TYPE; + $this->enum = $enum; + $this->title = $title; + $this->description = $description; + $this->enumNames = $enumNames; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'string', + * title?: string|null, + * description?: string|null, + * enum: array, + * enumNames?: array|null, + * default?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['enum']); + + return new self( + self::asStringArray($data['enum']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringArrayOrNull($data['enumNames'] ?? null), + self::asStringOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + $result['enum'] = $this->enum; + if ($this->enumNames !== null) { + $result['enumNames'] = $this->enumNames; + } + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'string' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array + */ + public function getEnum(): array + { + return $this->enum; + } + + /** + * @return array|null + */ + public function getEnumNames(): ?array + { + return $this->enumNames; + } + + /** + * @return string|null + */ + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/NumberSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/NumberSchema.php new file mode 100644 index 0000000000..1fa849356b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/NumberSchema.php @@ -0,0 +1,196 @@ +type = $type; + $this->title = $title; + $this->description = $description; + $this->minimum = $minimum; + $this->maximum = $maximum; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'number'|'integer', + * title?: string|null, + * description?: string|null, + * minimum?: float|null, + * maximum?: float|null, + * default?: float|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['type']); + + /** @var 'number'|'integer' $type */ + $type = self::asString($data['type']); + + return new self( + $type, + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asFloatOrNull($data['minimum'] ?? null), + self::asFloatOrNull($data['maximum'] ?? null), + self::asFloatOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->minimum !== null) { + $result['minimum'] = $this->minimum; + } + if ($this->maximum !== null) { + $result['maximum'] = $this->maximum; + } + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'number'|'integer' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return float|null + */ + public function getMinimum(): ?float + { + return $this->minimum; + } + + /** + * @return float|null + */ + public function getMaximum(): ?float + { + return $this->maximum; + } + + /** + * @return float|null + */ + public function getDefault(): ?float + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/StringSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/StringSchema.php new file mode 100644 index 0000000000..2e0f2cf0b5 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/StringSchema.php @@ -0,0 +1,221 @@ +type = self::TYPE; + $this->title = $title; + $this->description = $description; + $this->minLength = $minLength; + $this->maxLength = $maxLength; + $this->format = $format; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'string', + * title?: string|null, + * description?: string|null, + * minLength?: int|null, + * maxLength?: int|null, + * format?: 'email'|'uri'|'date'|'date-time'|null, + * default?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var 'email'|'uri'|'date'|'date-time'|null $format */ + $format = isset($data['format']) + ? self::asStringOrNull($data['format']) + : null; + + return new self( + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asIntOrNull($data['minLength'] ?? null), + self::asIntOrNull($data['maxLength'] ?? null), + $format, + self::asStringOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->minLength !== null) { + $result['minLength'] = $this->minLength; + } + if ($this->maxLength !== null) { + $result['maxLength'] = $this->maxLength; + } + if ($this->format !== null) { + $result['format'] = $this->format; + } + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'string' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return int|null + */ + public function getMinLength(): ?int + { + return $this->minLength; + } + + /** + * @return int|null + */ + public function getMaxLength(): ?int + { + return $this->maxLength; + } + + /** + * @return 'email'|'uri'|'date'|'date-time'|null + */ + public function getFormat(): ?string + { + return $this->format; + } + + /** + * @return string|null + */ + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchema.php new file mode 100644 index 0000000000..53b52de8f5 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchema.php @@ -0,0 +1,234 @@ +|null + */ + protected ?array $default; + + /** + * @param \WP\McpSchema\Client\Elicitation\DTO\TitledMultiSelectEnumSchemaItems $items @since 2025-11-25 + * @param string|null $title @since 2025-11-25 + * @param string|null $description @since 2025-11-25 + * @param int|null $minItems @since 2025-11-25 + * @param int|null $maxItems @since 2025-11-25 + * @param array|null $default @since 2025-11-25 + */ + public function __construct( + TitledMultiSelectEnumSchemaItems $items, + ?string $title = null, + ?string $description = null, + ?int $minItems = null, + ?int $maxItems = null, + ?array $default = null + ) { + $this->type = self::TYPE; + $this->items = $items; + $this->title = $title; + $this->description = $description; + $this->minItems = $minItems; + $this->maxItems = $maxItems; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'array', + * title?: string|null, + * description?: string|null, + * minItems?: int|null, + * maxItems?: int|null, + * items: array|\WP\McpSchema\Client\Elicitation\DTO\TitledMultiSelectEnumSchemaItems, + * default?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['items']); + + /** @var \WP\McpSchema\Client\Elicitation\DTO\TitledMultiSelectEnumSchemaItems $items */ + $items = is_array($data['items']) + ? TitledMultiSelectEnumSchemaItems::fromArray(self::asArray($data['items'])) + : $data['items']; + + return new self( + $items, + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asIntOrNull($data['minItems'] ?? null), + self::asIntOrNull($data['maxItems'] ?? null), + self::asStringArrayOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->minItems !== null) { + $result['minItems'] = $this->minItems; + } + if ($this->maxItems !== null) { + $result['maxItems'] = $this->maxItems; + } + $result['items'] = $this->items->toArray(); + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'array' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return int|null + */ + public function getMinItems(): ?int + { + return $this->minItems; + } + + /** + * @return int|null + */ + public function getMaxItems(): ?int + { + return $this->maxItems; + } + + /** + * @return \WP\McpSchema\Client\Elicitation\DTO\TitledMultiSelectEnumSchemaItems + */ + public function getItems(): TitledMultiSelectEnumSchemaItems + { + return $this->items; + } + + /** + * @return array|null + */ + public function getDefault(): ?array + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchemaItems.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchemaItems.php new file mode 100644 index 0000000000..f93372b581 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledMultiSelectEnumSchemaItems.php @@ -0,0 +1,48 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self(); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledSingleSelectEnumSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledSingleSelectEnumSchema.php new file mode 100644 index 0000000000..57e6d2c1cf --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/TitledSingleSelectEnumSchema.php @@ -0,0 +1,182 @@ + + */ + protected array $oneOf; + + /** + * Optional default value. + * + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $default; + + /** + * @param array $oneOf @since 2025-11-25 + * @param string|null $title @since 2025-11-25 + * @param string|null $description @since 2025-11-25 + * @param string|null $default @since 2025-11-25 + */ + public function __construct( + array $oneOf, + ?string $title = null, + ?string $description = null, + ?string $default = null + ) { + $this->type = self::TYPE; + $this->oneOf = $oneOf; + $this->title = $title; + $this->description = $description; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'string', + * title?: string|null, + * description?: string|null, + * oneOf: array, + * default?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['oneOf']); + + /** @var array $oneOf */ + $oneOf = self::asArray($data['oneOf']); + + return new self( + $oneOf, + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + $result['oneOf'] = $this->oneOf; + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'string' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array + */ + public function getOneOf(): array + { + return $this->oneOf; + } + + /** + * @return string|null + */ + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchema.php new file mode 100644 index 0000000000..7401780a91 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchema.php @@ -0,0 +1,234 @@ +|null + */ + protected ?array $default; + + /** + * @param \WP\McpSchema\Client\Elicitation\DTO\UntitledMultiSelectEnumSchemaItems $items @since 2025-11-25 + * @param string|null $title @since 2025-11-25 + * @param string|null $description @since 2025-11-25 + * @param int|null $minItems @since 2025-11-25 + * @param int|null $maxItems @since 2025-11-25 + * @param array|null $default @since 2025-11-25 + */ + public function __construct( + UntitledMultiSelectEnumSchemaItems $items, + ?string $title = null, + ?string $description = null, + ?int $minItems = null, + ?int $maxItems = null, + ?array $default = null + ) { + $this->type = self::TYPE; + $this->items = $items; + $this->title = $title; + $this->description = $description; + $this->minItems = $minItems; + $this->maxItems = $maxItems; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'array', + * title?: string|null, + * description?: string|null, + * minItems?: int|null, + * maxItems?: int|null, + * items: array|\WP\McpSchema\Client\Elicitation\DTO\UntitledMultiSelectEnumSchemaItems, + * default?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['items']); + + /** @var \WP\McpSchema\Client\Elicitation\DTO\UntitledMultiSelectEnumSchemaItems $items */ + $items = is_array($data['items']) + ? UntitledMultiSelectEnumSchemaItems::fromArray(self::asArray($data['items'])) + : $data['items']; + + return new self( + $items, + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asIntOrNull($data['minItems'] ?? null), + self::asIntOrNull($data['maxItems'] ?? null), + self::asStringArrayOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->minItems !== null) { + $result['minItems'] = $this->minItems; + } + if ($this->maxItems !== null) { + $result['maxItems'] = $this->maxItems; + } + $result['items'] = $this->items->toArray(); + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'array' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return int|null + */ + public function getMinItems(): ?int + { + return $this->minItems; + } + + /** + * @return int|null + */ + public function getMaxItems(): ?int + { + return $this->maxItems; + } + + /** + * @return \WP\McpSchema\Client\Elicitation\DTO\UntitledMultiSelectEnumSchemaItems + */ + public function getItems(): UntitledMultiSelectEnumSchemaItems + { + return $this->items; + } + + /** + * @return array|null + */ + public function getDefault(): ?array + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchemaItems.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchemaItems.php new file mode 100644 index 0000000000..72aecd1c2e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledMultiSelectEnumSchemaItems.php @@ -0,0 +1,94 @@ + + */ + protected array $enum; + + /** + * @param array $enum + */ + public function __construct( + array $enum + ) { + $this->type = self::TYPE; + $this->enum = $enum; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'string', + * enum: array + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['enum']); + + return new self( + self::asStringArray($data['enum']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['enum'] = $this->enum; + + return $result; + } + + /** + * @return 'string' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return array + */ + public function getEnum(): array + { + return $this->enum; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledSingleSelectEnumSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledSingleSelectEnumSchema.php new file mode 100644 index 0000000000..16d99e5f37 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/DTO/UntitledSingleSelectEnumSchema.php @@ -0,0 +1,179 @@ + + */ + protected array $enum; + + /** + * Optional default value. + * + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $default; + + /** + * @param array $enum @since 2025-11-25 + * @param string|null $title @since 2025-11-25 + * @param string|null $description @since 2025-11-25 + * @param string|null $default @since 2025-11-25 + */ + public function __construct( + array $enum, + ?string $title = null, + ?string $description = null, + ?string $default = null + ) { + $this->type = self::TYPE; + $this->enum = $enum; + $this->title = $title; + $this->description = $description; + $this->default = $default; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'string', + * title?: string|null, + * description?: string|null, + * enum: array, + * default?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['enum']); + + return new self( + self::asStringArray($data['enum']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['default'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->description !== null) { + $result['description'] = $this->description; + } + $result['enum'] = $this->enum; + if ($this->default !== null) { + $result['default'] = $this->default; + } + + return $result; + } + + /** + * @return 'string' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array + */ + public function getEnum(): array + { + return $this->enum; + } + + /** + * @return string|null + */ + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/ElicitRequestParamsFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/ElicitRequestParamsFactory.php new file mode 100644 index 0000000000..2a18234eef --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/ElicitRequestParamsFactory.php @@ -0,0 +1,79 @@ +> + */ + public const REGISTRY = [ + 'form' => ElicitRequestFormParams::class, + 'url' => ElicitRequestURLParams::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ElicitRequestParamsInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ElicitRequestParamsInterface + { + if (!isset($data['mode'])) { + throw new \InvalidArgumentException('Missing discriminator field: mode'); + } + + /** @var string $mode */ + $mode = $data['mode']; + if (!isset(self::REGISTRY[$mode])) { + throw new \InvalidArgumentException(sprintf( + "Unknown mode value '%s'. Valid values: %s", + $mode, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$mode]; + return $class::fromArray($data); + } + + /** + * Checks if a mode value is supported by this factory. + * + * @param string $mode + * @return bool + */ + public static function supports(string $mode): bool + { + return isset(self::REGISTRY[$mode]); + } + + /** + * Returns all supported mode values. + * + * @return array + */ + public static function modes(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/EnumSchemaFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/EnumSchemaFactory.php new file mode 100644 index 0000000000..b6d760ec1e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/EnumSchemaFactory.php @@ -0,0 +1,87 @@ + + */ + public const REGISTRY = [ + 'array' => MultiSelectEnumSchemaFactory::class, + 'string' => LegacyTitledEnumSchema::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return EnumSchemaInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): EnumSchemaInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + switch ($data['type']) { + case 'array': + return MultiSelectEnumSchemaFactory::fromArray($data); + case 'string': + if (isset($data['oneOf'])) { + return SingleSelectEnumSchemaFactory::fromArray($data); + } + elseif (isset($data['enumNames'])) { + return LegacyTitledEnumSchema::fromArray($data); + } + else { + return LegacyTitledEnumSchema::fromArray($data); + } + default: + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + is_scalar($data['type']) ? $data['type'] : gettype($data['type']), + implode(', ', array_keys(self::REGISTRY)) + )); + } + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/MultiSelectEnumSchemaFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/MultiSelectEnumSchemaFactory.php new file mode 100644 index 0000000000..5d58086963 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/MultiSelectEnumSchemaFactory.php @@ -0,0 +1,74 @@ +> + */ + public const REGISTRY = [ + 'array' => UntitledMultiSelectEnumSchema::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return MultiSelectEnumSchemaInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): MultiSelectEnumSchemaInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + switch ($data['type']) { + case 'array': + return UntitledMultiSelectEnumSchema::fromArray($data); + default: + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + is_scalar($data['type']) ? $data['type'] : gettype($data['type']), + implode(', ', array_keys(self::REGISTRY)) + )); + } + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/PrimitiveSchemaDefinitionFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/PrimitiveSchemaDefinitionFactory.php new file mode 100644 index 0000000000..dbc80e007a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/PrimitiveSchemaDefinitionFactory.php @@ -0,0 +1,100 @@ + + */ + public const REGISTRY = [ + 'number' => NumberSchema::class, + 'integer' => NumberSchema::class, + 'boolean' => BooleanSchema::class, + 'array' => EnumSchemaFactory::class, + 'string' => StringSchema::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return PrimitiveSchemaDefinitionInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): PrimitiveSchemaDefinitionInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + switch ($data['type']) { + case 'number': + return NumberSchema::fromArray($data); + case 'integer': + return NumberSchema::fromArray($data); + case 'boolean': + return BooleanSchema::fromArray($data); + case 'array': + return EnumSchemaFactory::fromArray($data); + case 'string': + if (isset($data['minLength'])) { + return StringSchema::fromArray($data); + } + elseif (isset($data['enum'])) { + return EnumSchemaFactory::fromArray($data); + } + else { + return StringSchema::fromArray($data); + } + default: + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + is_scalar($data['type']) ? $data['type'] : gettype($data['type']), + implode(', ', array_keys(self::REGISTRY)) + )); + } + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/SingleSelectEnumSchemaFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/SingleSelectEnumSchemaFactory.php new file mode 100644 index 0000000000..d2939cc74c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Factory/SingleSelectEnumSchemaFactory.php @@ -0,0 +1,82 @@ +> + */ + public const REGISTRY = [ + 'string' => UntitledSingleSelectEnumSchema::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return SingleSelectEnumSchemaInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): SingleSelectEnumSchemaInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + switch ($data['type']) { + case 'string': + if (isset($data['enum'])) { + return UntitledSingleSelectEnumSchema::fromArray($data); + } + elseif (isset($data['oneOf'])) { + return TitledSingleSelectEnumSchema::fromArray($data); + } + else { + return UntitledSingleSelectEnumSchema::fromArray($data); + } + default: + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + is_scalar($data['type']) ? $data['type'] : gettype($data['type']), + implode(', ', array_keys(self::REGISTRY)) + )); + } + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/ElicitRequestParamsInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/ElicitRequestParamsInterface.php new file mode 100644 index 0000000000..ff631a8afe --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/ElicitRequestParamsInterface.php @@ -0,0 +1,26 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/EnumSchemaInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/EnumSchemaInterface.php new file mode 100644 index 0000000000..dff30f44d2 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/EnumSchemaInterface.php @@ -0,0 +1,21 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/SingleSelectEnumSchemaInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/SingleSelectEnumSchemaInterface.php new file mode 100644 index 0000000000..ee5051a73f --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Elicitation/Union/SingleSelectEnumSchemaInterface.php @@ -0,0 +1,20 @@ +|null + */ + protected ?array $experimental; + + /** + * Present if the client supports listing roots. + * + * @since 2024-11-05 + * + * @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesRoots|null + */ + protected ?ClientCapabilitiesRoots $roots; + + /** + * Present if the client supports sampling from an LLM. + * + * @since 2024-11-05 + * + * @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesSampling|null + */ + protected ?ClientCapabilitiesSampling $sampling; + + /** + * Present if the client supports elicitation from the server. + * + * @since 2025-06-18 + * + * @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesElicitation|null + */ + protected ?ClientCapabilitiesElicitation $elicitation; + + /** + * Present if the client supports task-augmented requests. + * + * @since 2025-11-25 + * + * @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesTasks|null + */ + protected ?ClientCapabilitiesTasks $tasks; + + /** + * @param array|null $experimental @since 2024-11-05 + * @param \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesRoots|null $roots @since 2024-11-05 + * @param \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesSampling|null $sampling @since 2024-11-05 + * @param \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesElicitation|null $elicitation @since 2025-06-18 + * @param \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesTasks|null $tasks @since 2025-11-25 + */ + public function __construct( + ?array $experimental = null, + ?ClientCapabilitiesRoots $roots = null, + ?ClientCapabilitiesSampling $sampling = null, + ?ClientCapabilitiesElicitation $elicitation = null, + ?ClientCapabilitiesTasks $tasks = null + ) { + $this->experimental = $experimental; + $this->roots = $roots; + $this->sampling = $sampling; + $this->elicitation = $elicitation; + $this->tasks = $tasks; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * experimental?: array|null, + * roots?: array|\WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesRoots|null, + * sampling?: array|\WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesSampling|null, + * elicitation?: array|\WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesElicitation|null, + * tasks?: array|\WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesTasks|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesRoots|null $roots */ + $roots = isset($data['roots']) + ? (is_array($data['roots']) + ? ClientCapabilitiesRoots::fromArray(self::asArray($data['roots'])) + : $data['roots']) + : null; + + /** @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesSampling|null $sampling */ + $sampling = isset($data['sampling']) + ? (is_array($data['sampling']) + ? ClientCapabilitiesSampling::fromArray(self::asArray($data['sampling'])) + : $data['sampling']) + : null; + + /** @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesElicitation|null $elicitation */ + $elicitation = isset($data['elicitation']) + ? (is_array($data['elicitation']) + ? ClientCapabilitiesElicitation::fromArray(self::asArray($data['elicitation'])) + : $data['elicitation']) + : null; + + /** @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesTasks|null $tasks */ + $tasks = isset($data['tasks']) + ? (is_array($data['tasks']) + ? ClientCapabilitiesTasks::fromArray(self::asArray($data['tasks'])) + : $data['tasks']) + : null; + + return new self( + self::asObjectMapOrNull($data['experimental'] ?? null), + $roots, + $sampling, + $elicitation, + $tasks + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->experimental !== null) { + $result['experimental'] = $this->experimental; + } + if ($this->roots !== null) { + $result['roots'] = $this->roots->toArray(); + } + if ($this->sampling !== null) { + $result['sampling'] = $this->sampling->toArray(); + } + if ($this->elicitation !== null) { + $result['elicitation'] = $this->elicitation->toArray(); + } + if ($this->tasks !== null) { + $result['tasks'] = $this->tasks->toArray(); + } + + return $result; + } + + /** + * @return array|null + */ + public function getExperimental(): ?array + { + return $this->experimental; + } + + /** + * @return \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesRoots|null + */ + public function getRoots(): ?ClientCapabilitiesRoots + { + return $this->roots; + } + + /** + * @return \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesSampling|null + */ + public function getSampling(): ?ClientCapabilitiesSampling + { + return $this->sampling; + } + + /** + * @return \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesElicitation|null + */ + public function getElicitation(): ?ClientCapabilitiesElicitation + { + return $this->elicitation; + } + + /** + * @return \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilitiesTasks|null + */ + public function getTasks(): ?ClientCapabilitiesTasks + { + return $this->tasks; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesElicitation.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesElicitation.php new file mode 100644 index 0000000000..b0404157fa --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesElicitation.php @@ -0,0 +1,95 @@ +form = $form; + $this->url = $url; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * form?: object|null, + * url?: object|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asObjectOrNull($data['form'] ?? null), + self::asObjectOrNull($data['url'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->form !== null) { + $result['form'] = $this->form; + } + if ($this->url !== null) { + $result['url'] = $this->url; + } + + return $result; + } + + /** + * @return object|null + */ + public function getForm(): ?object + { + return $this->form; + } + + /** + * @return object|null + */ + public function getUrl(): ?object + { + return $this->url; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesRoots.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesRoots.php new file mode 100644 index 0000000000..3c20ba7bff --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesRoots.php @@ -0,0 +1,76 @@ +listChanged = $listChanged; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * listChanged?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asBoolOrNull($data['listChanged'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->listChanged !== null) { + $result['listChanged'] = $this->listChanged; + } + + return $result; + } + + /** + * @return bool|null + */ + public function getListChanged(): ?bool + { + return $this->listChanged; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesSampling.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesSampling.php new file mode 100644 index 0000000000..8cc618c25f --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesSampling.php @@ -0,0 +1,99 @@ +context = $context; + $this->tools = $tools; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * context?: object|null, + * tools?: object|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asObjectOrNull($data['context'] ?? null), + self::asObjectOrNull($data['tools'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->context !== null) { + $result['context'] = $this->context; + } + if ($this->tools !== null) { + $result['tools'] = $this->tools; + } + + return $result; + } + + /** + * @return object|null + */ + public function getContext(): ?object + { + return $this->context; + } + + /** + * @return object|null + */ + public function getTools(): ?object + { + return $this->tools; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesTasks.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesTasks.php new file mode 100644 index 0000000000..89fe9cea9a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/DTO/ClientCapabilitiesTasks.php @@ -0,0 +1,99 @@ +list = $list; + $this->cancel = $cancel; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * list?: object|null, + * cancel?: object|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asObjectOrNull($data['list'] ?? null), + self::asObjectOrNull($data['cancel'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->list !== null) { + $result['list'] = $this->list; + } + if ($this->cancel !== null) { + $result['cancel'] = $this->cancel; + } + + return $result; + } + + /** + * @return object|null + */ + public function getList(): ?object + { + return $this->list; + } + + /** + * @return object|null + */ + public function getCancel(): ?object + { + return $this->cancel; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/Union/ClientResultInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/Union/ClientResultInterface.php new file mode 100644 index 0000000000..55673a2531 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Lifecycle/Union/ClientResultInterface.php @@ -0,0 +1,30 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsRequest.php new file mode 100644 index 0000000000..25c5a231e7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsRequest.php @@ -0,0 +1,115 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'roots/list', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? RequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null + */ + public function getTypedParams(): ?RequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsResult.php new file mode 100644 index 0000000000..ee1a87bba2 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/ListRootsResult.php @@ -0,0 +1,105 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'roots']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Client\Roots\DTO\Root> + */ + protected array $roots; + + /** + * @param array<\WP\McpSchema\Client\Roots\DTO\Root> $roots @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $roots, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->roots = $roots; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * roots: array|\WP\McpSchema\Client\Roots\DTO\Root> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['roots']); + + /** @var array<\WP\McpSchema\Client\Roots\DTO\Root> $roots */ + $roots = array_map( + static fn($item) => is_array($item) + ? Root::fromArray($item) + : $item, + self::asArray($data['roots']) + ); + + return new self( + $roots, + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['roots'] = array_map(static fn($item) => $item->toArray(), $this->roots); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Client\Roots\DTO\Root> + */ + public function getRoots(): array + { + return $this->roots; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/Root.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/Root.php new file mode 100644 index 0000000000..4228a58fcb --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/Root.php @@ -0,0 +1,135 @@ +|null + */ + protected ?array $_meta; + + /** + * @param string $uri @since 2024-11-05 + * @param string|null $name @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + string $uri, + ?string $name = null, + ?array $_meta = null + ) { + $this->uri = $uri; + $this->name = $name; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * uri: string, + * name?: string|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + return new self( + self::asString($data['uri']), + self::asStringOrNull($data['name'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['uri'] = $this->uri; + if ($this->name !== null) { + $result['name'] = $this->name; + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/RootsListChangedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/RootsListChangedNotification.php new file mode 100644 index 0000000000..045f05673d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Roots/DTO/RootsListChangedNotification.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/roots/list_changed', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? NotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + */ + public function getTypedParams(): ?NotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequest.php new file mode 100644 index 0000000000..0b4d9e9b5b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'sampling/createMessage', + * params: array|\WP\McpSchema\Client\Sampling\DTO\CreateMessageRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Client\Sampling\DTO\CreateMessageRequestParams $params */ + $params = is_array($data['params']) + ? CreateMessageRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Client\Sampling\DTO\CreateMessageRequestParams + */ + public function getTypedParams(): CreateMessageRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequestParams.php new file mode 100644 index 0000000000..91ed200e15 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageRequestParams.php @@ -0,0 +1,369 @@ + + */ + protected array $messages; + + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + * + * @since 2025-11-25 + * + * @var \WP\McpSchema\Client\Sampling\DTO\ModelPreferences|null + */ + protected ?ModelPreferences $modelPreferences; + + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + * + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $systemPrompt; + + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + * + * @since 2025-11-25 + * + * @var 'none'|'thisServer'|'allServers'|null + */ + protected ?string $includeContext; + + /** + * @since 2025-11-25 + * + * @var float|null + */ + protected ?float $temperature; + + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + * + * @since 2025-11-25 + * + * @var float + */ + protected float $maxTokens; + + /** + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $stopSequences; + + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + * + * @since 2025-11-25 + * + * @var object|null + */ + protected ?object $metadata; + + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Server\Tools\DTO\Tool>|null + */ + protected ?array $tools; + + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * Default is `{ mode: "auto" }`. + * + * @since 2025-11-25 + * + * @var \WP\McpSchema\Client\Sampling\DTO\ToolChoice|null + */ + protected ?ToolChoice $toolChoice; + + /** + * @param array<\WP\McpSchema\Client\Sampling\DTO\SamplingMessage> $messages @since 2025-11-25 + * @param float $maxTokens @since 2025-11-25 + * @param \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task @since 2025-11-25 + * @param \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta @since 2025-11-25 + * @param \WP\McpSchema\Client\Sampling\DTO\ModelPreferences|null $modelPreferences @since 2025-11-25 + * @param string|null $systemPrompt @since 2025-11-25 + * @param 'none'|'thisServer'|'allServers'|null $includeContext @since 2025-11-25 + * @param float|null $temperature @since 2025-11-25 + * @param array|null $stopSequences @since 2025-11-25 + * @param object|null $metadata @since 2025-11-25 + * @param array<\WP\McpSchema\Server\Tools\DTO\Tool>|null $tools @since 2025-11-25 + * @param \WP\McpSchema\Client\Sampling\DTO\ToolChoice|null $toolChoice @since 2025-11-25 + */ + public function __construct( + array $messages, + float $maxTokens, + ?TaskMetadata $task = null, + ?RequestParamsMeta $_meta = null, + ?ModelPreferences $modelPreferences = null, + ?string $systemPrompt = null, + ?string $includeContext = null, + ?float $temperature = null, + ?array $stopSequences = null, + ?object $metadata = null, + ?array $tools = null, + ?ToolChoice $toolChoice = null + ) { + parent::__construct($_meta, $task); + $this->messages = $messages; + $this->maxTokens = $maxTokens; + $this->modelPreferences = $modelPreferences; + $this->systemPrompt = $systemPrompt; + $this->includeContext = $includeContext; + $this->temperature = $temperature; + $this->stopSequences = $stopSequences; + $this->metadata = $metadata; + $this->tools = $tools; + $this->toolChoice = $toolChoice; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * task?: array|\WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null, + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * messages: array|\WP\McpSchema\Client\Sampling\DTO\SamplingMessage>, + * modelPreferences?: array|\WP\McpSchema\Client\Sampling\DTO\ModelPreferences|null, + * systemPrompt?: string|null, + * includeContext?: 'none'|'thisServer'|'allServers'|null, + * temperature?: float|null, + * maxTokens: float, + * stopSequences?: array|null, + * metadata?: object|null, + * tools?: array|\WP\McpSchema\Server\Tools\DTO\Tool>|null, + * toolChoice?: array|\WP\McpSchema\Client\Sampling\DTO\ToolChoice|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['messages', 'maxTokens']); + + /** @var array<\WP\McpSchema\Client\Sampling\DTO\SamplingMessage> $messages */ + $messages = array_map( + static fn($item) => is_array($item) + ? SamplingMessage::fromArray($item) + : $item, + self::asArray($data['messages']) + ); + + /** @var \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task */ + $task = isset($data['task']) + ? (is_array($data['task']) + ? TaskMetadata::fromArray(self::asArray($data['task'])) + : $data['task']) + : null; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + /** @var \WP\McpSchema\Client\Sampling\DTO\ModelPreferences|null $modelPreferences */ + $modelPreferences = isset($data['modelPreferences']) + ? (is_array($data['modelPreferences']) + ? ModelPreferences::fromArray(self::asArray($data['modelPreferences'])) + : $data['modelPreferences']) + : null; + + /** @var 'none'|'thisServer'|'allServers'|null $includeContext */ + $includeContext = isset($data['includeContext']) + ? self::asStringOrNull($data['includeContext']) + : null; + + /** @var array<\WP\McpSchema\Server\Tools\DTO\Tool>|null $tools */ + $tools = isset($data['tools']) + ? array_map( + static fn($item) => is_array($item) + ? Tool::fromArray($item) + : $item, + self::asArray($data['tools']) + ) + : null; + + /** @var \WP\McpSchema\Client\Sampling\DTO\ToolChoice|null $toolChoice */ + $toolChoice = isset($data['toolChoice']) + ? (is_array($data['toolChoice']) + ? ToolChoice::fromArray(self::asArray($data['toolChoice'])) + : $data['toolChoice']) + : null; + + return new self( + $messages, + self::asFloat($data['maxTokens']), + $task, + $_meta, + $modelPreferences, + self::asStringOrNull($data['systemPrompt'] ?? null), + $includeContext, + self::asFloatOrNull($data['temperature'] ?? null), + self::asStringArrayOrNull($data['stopSequences'] ?? null), + self::asObjectOrNull($data['metadata'] ?? null), + $tools, + $toolChoice + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['messages'] = array_map(static fn($item) => $item->toArray(), $this->messages); + if ($this->modelPreferences !== null) { + $result['modelPreferences'] = $this->modelPreferences->toArray(); + } + if ($this->systemPrompt !== null) { + $result['systemPrompt'] = $this->systemPrompt; + } + if ($this->includeContext !== null) { + $result['includeContext'] = $this->includeContext; + } + if ($this->temperature !== null) { + $result['temperature'] = $this->temperature; + } + $result['maxTokens'] = $this->maxTokens; + if ($this->stopSequences !== null) { + $result['stopSequences'] = $this->stopSequences; + } + if ($this->metadata !== null) { + $result['metadata'] = $this->metadata; + } + if ($this->tools !== null) { + $result['tools'] = array_map(static fn($item) => $item->toArray(), $this->tools); + } + if ($this->toolChoice !== null) { + $result['toolChoice'] = $this->toolChoice->toArray(); + } + + return $result; + } + + /** + * @return array<\WP\McpSchema\Client\Sampling\DTO\SamplingMessage> + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * @return \WP\McpSchema\Client\Sampling\DTO\ModelPreferences|null + */ + public function getModelPreferences(): ?ModelPreferences + { + return $this->modelPreferences; + } + + /** + * @return string|null + */ + public function getSystemPrompt(): ?string + { + return $this->systemPrompt; + } + + /** + * @return 'none'|'thisServer'|'allServers'|null + */ + public function getIncludeContext(): ?string + { + return $this->includeContext; + } + + /** + * @return float|null + */ + public function getTemperature(): ?float + { + return $this->temperature; + } + + /** + * @return float + */ + public function getMaxTokens(): float + { + return $this->maxTokens; + } + + /** + * @return array|null + */ + public function getStopSequences(): ?array + { + return $this->stopSequences; + } + + /** + * @return object|null + */ + public function getMetadata(): ?object + { + return $this->metadata; + } + + /** + * @return array<\WP\McpSchema\Server\Tools\DTO\Tool>|null + */ + public function getTools(): ?array + { + return $this->tools; + } + + /** + * @return \WP\McpSchema\Client\Sampling\DTO\ToolChoice|null + */ + public function getToolChoice(): ?ToolChoice + { + return $this->toolChoice; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageResult.php new file mode 100644 index 0000000000..f6384b6747 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/CreateMessageResult.php @@ -0,0 +1,191 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'model', 'stopReason', 'role', 'content']; + + /** + * The name of the model that generated the message. + * + * @since 2024-11-05 + * + * @var string + */ + protected string $model; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * - "toolUse": The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + * + * @since 2024-11-05 + * + * @var "endTurn"|"stopSequence"|"maxTokens"|"toolUse"|string|null + */ + protected $stopReason; + + /** + * @since 2024-11-05 + * + * @var 'user'|'assistant' + */ + protected string $role; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> + */ + protected array $content; + + /** + * @param string $model @since 2024-11-05 + * @param 'user'|'assistant' $role @since 2024-11-05 + * @param array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> $content @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param "endTurn"|"stopSequence"|"maxTokens"|"toolUse"|string|null $stopReason @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + string $model, + string $role, + array $content, + ?array $_meta = null, + $stopReason = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->model = $model; + $this->role = $role; + $this->content = $content; + $this->stopReason = $stopReason; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * model: string, + * stopReason?: "endTurn"|"stopSequence"|"maxTokens"|"toolUse"|string|null, + * role: 'user'|'assistant', + * content: array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['model', 'role', 'content']); + + /** @var 'user'|'assistant' $role */ + $role = self::asString($data['role']); + + /** @var array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> $content */ + $content = array_map( + static fn($item) => is_array($item) + ? SamplingMessageContentBlockFactory::fromArray($item) + : $item, + self::asArray($data['content']) + ); + + /** @var "endTurn"|"stopSequence"|"maxTokens"|"toolUse"|string|null $stopReason */ + $stopReason = isset($data['stopReason']) + ? $data['stopReason'] + : null; + + return new self( + self::asString($data['model']), + $role, + $content, + self::asArrayOrNull($data['_meta'] ?? null), + $stopReason, + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['model'] = $this->model; + if ($this->stopReason !== null) { + $result['stopReason'] = $this->stopReason; + } + $result['role'] = $this->role; + $result['content'] = array_map(static fn($item) => (is_object($item) && method_exists($item, 'toArray')) ? $item->toArray() : $item, $this->content); + + return $result; + } + + /** + * @return string + */ + public function getModel(): string + { + return $this->model; + } + + /** + * @return "endTurn"|"stopSequence"|"maxTokens"|"toolUse"|string|null + */ + public function getStopReason() + { + return $this->stopReason; + } + + /** + * @return 'user'|'assistant' + */ + public function getRole(): string + { + return $this->role; + } + + /** + * @return array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> + */ + public function getContent(): array + { + return $this->content; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelHint.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelHint.php new file mode 100644 index 0000000000..33765a97ef --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelHint.php @@ -0,0 +1,91 @@ +name = $name; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['name'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->name !== null) { + $result['name'] = $this->name; + } + + return $result; + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelPreferences.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelPreferences.php new file mode 100644 index 0000000000..72c2570066 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ModelPreferences.php @@ -0,0 +1,188 @@ +|null + */ + protected ?array $hints; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @since 2024-11-05 + * + * @var float|null + */ + protected ?float $costPriority; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @since 2024-11-05 + * + * @var float|null + */ + protected ?float $speedPriority; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @since 2024-11-05 + * + * @var float|null + */ + protected ?float $intelligencePriority; + + /** + * @param array<\WP\McpSchema\Client\Sampling\DTO\ModelHint>|null $hints @since 2024-11-05 + * @param float|null $costPriority @since 2024-11-05 + * @param float|null $speedPriority @since 2024-11-05 + * @param float|null $intelligencePriority @since 2024-11-05 + */ + public function __construct( + ?array $hints = null, + ?float $costPriority = null, + ?float $speedPriority = null, + ?float $intelligencePriority = null + ) { + $this->hints = $hints; + $this->costPriority = $costPriority; + $this->speedPriority = $speedPriority; + $this->intelligencePriority = $intelligencePriority; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * hints?: array|\WP\McpSchema\Client\Sampling\DTO\ModelHint>|null, + * costPriority?: float|null, + * speedPriority?: float|null, + * intelligencePriority?: float|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var array<\WP\McpSchema\Client\Sampling\DTO\ModelHint>|null $hints */ + $hints = isset($data['hints']) + ? array_map( + static fn($item) => is_array($item) + ? ModelHint::fromArray($item) + : $item, + self::asArray($data['hints']) + ) + : null; + + return new self( + $hints, + self::asFloatOrNull($data['costPriority'] ?? null), + self::asFloatOrNull($data['speedPriority'] ?? null), + self::asFloatOrNull($data['intelligencePriority'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->hints !== null) { + $result['hints'] = array_map(static fn($item) => $item->toArray(), $this->hints); + } + if ($this->costPriority !== null) { + $result['costPriority'] = $this->costPriority; + } + if ($this->speedPriority !== null) { + $result['speedPriority'] = $this->speedPriority; + } + if ($this->intelligencePriority !== null) { + $result['intelligencePriority'] = $this->intelligencePriority; + } + + return $result; + } + + /** + * @return array<\WP\McpSchema\Client\Sampling\DTO\ModelHint>|null + */ + public function getHints(): ?array + { + return $this->hints; + } + + /** + * @return float|null + */ + public function getCostPriority(): ?float + { + return $this->costPriority; + } + + /** + * @return float|null + */ + public function getSpeedPriority(): ?float + { + return $this->speedPriority; + } + + /** + * @return float|null + */ + public function getIntelligencePriority(): ?float + { + return $this->intelligencePriority; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/SamplingMessage.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/SamplingMessage.php new file mode 100644 index 0000000000..391d6cd795 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/SamplingMessage.php @@ -0,0 +1,137 @@ + + */ + protected array $content; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + * + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $_meta; + + /** + * @param 'user'|'assistant' $role @since 2024-11-05 + * @param array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> $content @since 2024-11-05 + * @param array|null $_meta @since 2025-11-25 + */ + public function __construct( + string $role, + array $content, + ?array $_meta = null + ) { + $this->role = $role; + $this->content = $content; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * role: 'user'|'assistant', + * content: array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface>, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['role', 'content']); + + /** @var 'user'|'assistant' $role */ + $role = self::asString($data['role']); + + /** @var array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> $content */ + $content = array_map( + static fn($item) => is_array($item) + ? SamplingMessageContentBlockFactory::fromArray($item) + : $item, + self::asArray($data['content']) + ); + + return new self( + $role, + $content, + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['role'] = $this->role; + $result['content'] = array_map(static fn($item) => (is_object($item) && method_exists($item, 'toArray')) ? $item->toArray() : $item, $this->content); + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'user'|'assistant' + */ + public function getRole(): string + { + return $this->role; + } + + /** + * @return array<\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface|\WP\McpSchema\Common\Protocol\Union\SamplingMessageContentBlockInterface> + */ + public function getContent(): array + { + return $this->content; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolChoice.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolChoice.php new file mode 100644 index 0000000000..49b5d194c5 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolChoice.php @@ -0,0 +1,88 @@ +mode = $mode; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * mode?: 'auto'|'required'|'none'|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var 'auto'|'required'|'none'|null $mode */ + $mode = isset($data['mode']) + ? self::asStringOrNull($data['mode']) + : null; + + return new self( + $mode + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->mode !== null) { + $result['mode'] = $this->mode; + } + + return $result; + } + + /** + * @return 'auto'|'required'|'none'|null + */ + public function getMode(): ?string + { + return $this->mode; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolResultContent.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolResultContent.php new file mode 100644 index 0000000000..a47af63c97 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolResultContent.php @@ -0,0 +1,225 @@ + + */ + protected array $content; + + /** + * An optional structured result object. + * + * If the tool defined an outputSchema, this SHOULD conform to that schema. + * + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $structuredContent; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + * + * @since 2025-11-25 + * + * @var bool|null + */ + protected ?bool $isError; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + * + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $_meta; + + /** + * @param string $toolUseId @since 2025-11-25 + * @param array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> $content @since 2025-11-25 + * @param array|null $structuredContent @since 2025-11-25 + * @param bool|null $isError @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + */ + public function __construct( + string $toolUseId, + array $content, + ?array $structuredContent = null, + ?bool $isError = null, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->toolUseId = $toolUseId; + $this->content = $content; + $this->structuredContent = $structuredContent; + $this->isError = $isError; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'tool_result', + * toolUseId: string, + * content: array|\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface>, + * structuredContent?: array|null, + * isError?: bool|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['toolUseId', 'content']); + + /** @var array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> $content */ + $content = array_map( + static fn($item) => is_array($item) + ? ContentBlockFactory::fromArray($item) + : $item, + self::asArray($data['content']) + ); + + return new self( + self::asString($data['toolUseId']), + $content, + self::asArrayOrNull($data['structuredContent'] ?? null), + self::asBoolOrNull($data['isError'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['toolUseId'] = $this->toolUseId; + $result['content'] = array_map(static fn($item) => $item->toArray(), $this->content); + if ($this->structuredContent !== null) { + $result['structuredContent'] = $this->structuredContent; + } + if ($this->isError !== null) { + $result['isError'] = $this->isError; + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'tool_result' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getToolUseId(): string + { + return $this->toolUseId; + } + + /** + * @return array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> + */ + public function getContent(): array + { + return $this->content; + } + + /** + * @return array|null + */ + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } + + /** + * @return bool|null + */ + public function getIsError(): ?bool + { + return $this->isError; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolUseContent.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolUseContent.php new file mode 100644 index 0000000000..70054e6957 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Sampling/DTO/ToolUseContent.php @@ -0,0 +1,180 @@ + + */ + protected array $input; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + * + * @since 2025-11-25 + * + * @var array|null + */ + protected ?array $_meta; + + /** + * @param string $id @since 2025-11-25 + * @param string $name @since 2025-11-25 + * @param array $input @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + */ + public function __construct( + string $id, + string $name, + array $input, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->id = $id; + $this->name = $name; + $this->input = $input; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'tool_use', + * id: string, + * name: string, + * input: array, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['id', 'name', 'input']); + + return new self( + self::asString($data['id']), + self::asString($data['name']), + self::asArray($data['input']), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['id'] = $this->id; + $result['name'] = $this->name; + $result['input'] = $this->input; + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'tool_use' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return array + */ + public function getInput(): array + { + return $this->input; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/CreateTaskResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/CreateTaskResult.php new file mode 100644 index 0000000000..b59087b634 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/CreateTaskResult.php @@ -0,0 +1,98 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'task']; + + /** + * @since 2025-11-25 + * + * @var \WP\McpSchema\Client\Tasks\DTO\Task + */ + protected Task $task; + + /** + * @param \WP\McpSchema\Client\Tasks\DTO\Task $task @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + * @param array|null $additionalProperties + */ + public function __construct( + Task $task, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->task = $task; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * task: array|\WP\McpSchema\Client\Tasks\DTO\Task + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['task']); + + /** @var \WP\McpSchema\Client\Tasks\DTO\Task $task */ + $task = is_array($data['task']) + ? Task::fromArray(self::asArray($data['task'])) + : $data['task']; + + return new self( + $task, + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['task'] = $this->task->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Client\Tasks\DTO\Task + */ + public function getTask(): Task + { + return $this->task; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/RelatedTaskMetadata.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/RelatedTaskMetadata.php new file mode 100644 index 0000000000..d0b40554f4 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/RelatedTaskMetadata.php @@ -0,0 +1,81 @@ +taskId = $taskId; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskId: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId']); + + return new self( + self::asString($data['taskId']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['taskId'] = $this->taskId; + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/Task.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/Task.php new file mode 100644 index 0000000000..f902db8ac4 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/Task.php @@ -0,0 +1,231 @@ +taskId = $taskId; + $this->status = $status; + $this->createdAt = $createdAt; + $this->lastUpdatedAt = $lastUpdatedAt; + $this->ttl = $ttl; + $this->statusMessage = $statusMessage; + $this->pollInterval = $pollInterval; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskId: string, + * status: 'working'|'input_required'|'completed'|'failed'|'cancelled', + * statusMessage?: string|null, + * createdAt: string, + * lastUpdatedAt: string, + * ttl: int|null, + * pollInterval?: int|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId', 'status', 'createdAt', 'lastUpdatedAt', 'ttl']); + + /** @var 'working'|'input_required'|'completed'|'failed'|'cancelled' $status */ + $status = self::asString($data['status']); + + return new self( + self::asString($data['taskId']), + $status, + self::asString($data['createdAt']), + self::asString($data['lastUpdatedAt']), + self::asInt($data['ttl']), + self::asStringOrNull($data['statusMessage'] ?? null), + self::asIntOrNull($data['pollInterval'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['taskId'] = $this->taskId; + $result['status'] = $this->status; + if ($this->statusMessage !== null) { + $result['statusMessage'] = $this->statusMessage; + } + $result['createdAt'] = $this->createdAt; + $result['lastUpdatedAt'] = $this->lastUpdatedAt; + if ($this->ttl !== null) { + $result['ttl'] = $this->ttl; + } + if ($this->pollInterval !== null) { + $result['pollInterval'] = $this->pollInterval; + } + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } + + /** + * @return 'working'|'input_required'|'completed'|'failed'|'cancelled' + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * @return string|null + */ + public function getStatusMessage(): ?string + { + return $this->statusMessage; + } + + /** + * @return string + */ + public function getCreatedAt(): string + { + return $this->createdAt; + } + + /** + * @return string + */ + public function getLastUpdatedAt(): string + { + return $this->lastUpdatedAt; + } + + /** + * @return int|null + */ + public function getTtl(): ?int + { + return $this->ttl; + } + + /** + * @return int|null + */ + public function getPollInterval(): ?int + { + return $this->pollInterval; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/TaskMetadata.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/TaskMetadata.php new file mode 100644 index 0000000000..0a4d624a32 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/DTO/TaskMetadata.php @@ -0,0 +1,81 @@ +ttl = $ttl; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * ttl?: int|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asIntOrNull($data['ttl'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->ttl !== null) { + $result['ttl'] = $this->ttl; + } + + return $result; + } + + /** + * @return int|null + */ + public function getTtl(): ?int + { + return $this->ttl; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/Enum/TaskStatus.php b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/Enum/TaskStatus.php new file mode 100644 index 0000000000..705cb7a3c5 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Client/Tasks/Enum/TaskStatus.php @@ -0,0 +1,64 @@ + $data + * @return static + */ + abstract public static function fromArray(array $data): self; + + /** + * Converts the instance to an array. + * + * @return array + */ + abstract public function toArray(): array; + + /** + * Converts the instance to JSON. + * + * @return string + */ + public function toJson(): string + { + return json_encode($this->toArray(), JSON_THROW_ON_ERROR); + } + + /** + * Creates an instance from JSON. + * + * @param string $json + * @return static + */ + public static function fromJson(string $json): self + { + /** @var array $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + return static::fromArray($data); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/AbstractEnum.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/AbstractEnum.php new file mode 100644 index 0000000000..8a6efe497a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/AbstractEnum.php @@ -0,0 +1,115 @@ + */ + private static array $instances = []; + + /** + * @param string $value + */ + private function __construct(string $value) + { + $this->value = $value; + } + + /** + * Creates an instance from a value. + * + * @param string $value + * @return static + * @throws \InvalidArgumentException + */ + public static function from(string $value): self + { + $values = static::values(); + if (!in_array($value, $values, true)) { + throw new \InvalidArgumentException( + sprintf('Invalid enum value: %s. Valid values: %s', $value, implode(', ', $values)) + ); + } + + $key = static::class . '::' . $value; + if (!isset(self::$instances[$key])) { + /** @phpstan-ignore new.static (Intentional: private constructor prevents subclass override) */ + self::$instances[$key] = new static($value); + } + + return self::$instances[$key]; + } + + /** + * Creates an instance from a value, or null if invalid. + * + * @param string $value + * @return static|null + */ + public static function tryFrom(string $value): ?self + { + try { + return static::from($value); + } catch (\InvalidArgumentException $e) { + return null; + } + } + + /** + * Returns all valid values for this enum. + * + * @return string[] + */ + abstract public static function values(): array; + + /** + * Returns all cases as instances. + * + * @return static[] + */ + public static function cases(): array + { + return array_map(fn(string $value) => static::from($value), static::values()); + } + + /** + * Gets the enum value. + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Converts to string. + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Compares with another instance. + * + * @param self $other + * @return bool + */ + public function equals(self $other): bool + { + return $this->value === $other->value && static::class === get_class($other); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/AudioContent.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/AudioContent.php new file mode 100644 index 0000000000..f86505588a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/AudioContent.php @@ -0,0 +1,187 @@ +|null + */ + protected ?array $_meta; + + /** + * @param string $data @since 2025-03-26 + * @param string $mimeType @since 2025-03-26 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2025-03-26 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + string $data, + string $mimeType, + ?Annotations $annotations = null, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->data = $data; + $this->mimeType = $mimeType; + $this->annotations = $annotations; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'audio', + * data: string, + * mimeType: string, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['data', 'mimeType']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + return new self( + self::asString($data['data']), + self::asString($data['mimeType']), + $annotations, + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['data'] = $this->data; + $result['mimeType'] = $this->mimeType; + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'audio' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getData(): string + { + return $this->data; + } + + /** + * @return string + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/ImageContent.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/ImageContent.php new file mode 100644 index 0000000000..d142164502 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/ImageContent.php @@ -0,0 +1,187 @@ +|null + */ + protected ?array $_meta; + + /** + * @param string $data @since 2024-11-05 + * @param string $mimeType @since 2024-11-05 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + string $data, + string $mimeType, + ?Annotations $annotations = null, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->data = $data; + $this->mimeType = $mimeType; + $this->annotations = $annotations; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'image', + * data: string, + * mimeType: string, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['data', 'mimeType']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + return new self( + self::asString($data['data']), + self::asString($data['mimeType']), + $annotations, + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['data'] = $this->data; + $result['mimeType'] = $this->mimeType; + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'image' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getData(): string + { + return $this->data; + } + + /** + * @return string + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/TextContent.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/TextContent.php new file mode 100644 index 0000000000..d1f669ade4 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Content/DTO/TextContent.php @@ -0,0 +1,164 @@ +|null + */ + protected ?array $_meta; + + /** + * @param string $text @since 2024-11-05 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + string $text, + ?Annotations $annotations = null, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->text = $text; + $this->annotations = $annotations; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'text', + * text: string, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['text']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + return new self( + self::asString($data['text']), + $annotations, + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['text'] = $this->text; + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'text' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getText(): string + { + return $this->text; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/BaseMetadataInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/BaseMetadataInterface.php new file mode 100644 index 0000000000..7891dccc74 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/BaseMetadataInterface.php @@ -0,0 +1,17 @@ + The array representation. + */ + public function toArray(): array; + + /** + * Creates an instance from array data. + * + * @param array $data The array data. + * @return static The created instance. + */ + public static function fromArray(array $data); +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/WithJsonSchemaInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/WithJsonSchemaInterface.php new file mode 100644 index 0000000000..3980b1cce8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Contracts/WithJsonSchemaInterface.php @@ -0,0 +1,20 @@ + The JSON Schema definition. + */ + public static function getJsonSchema(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Core/DTO/Icon.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Core/DTO/Icon.php new file mode 100644 index 0000000000..52a1778161 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Core/DTO/Icon.php @@ -0,0 +1,175 @@ +|null + */ + protected ?array $sizes; + + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + * + * @since 2025-11-25 + * + * @var 'light'|'dark'|null + */ + protected ?string $theme; + + /** + * @param string $src @since 2025-11-25 + * @param string|null $mimeType @since 2025-11-25 + * @param array|null $sizes @since 2025-11-25 + * @param 'light'|'dark'|null $theme @since 2025-11-25 + */ + public function __construct( + string $src, + ?string $mimeType = null, + ?array $sizes = null, + ?string $theme = null + ) { + $this->src = $src; + $this->mimeType = $mimeType; + $this->sizes = $sizes; + $this->theme = $theme; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * src: string, + * mimeType?: string|null, + * sizes?: array|null, + * theme?: 'light'|'dark'|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['src']); + + /** @var 'light'|'dark'|null $theme */ + $theme = isset($data['theme']) + ? self::asStringOrNull($data['theme']) + : null; + + return new self( + self::asString($data['src']), + self::asStringOrNull($data['mimeType'] ?? null), + self::asStringArrayOrNull($data['sizes'] ?? null), + $theme + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['src'] = $this->src; + if ($this->mimeType !== null) { + $result['mimeType'] = $this->mimeType; + } + if ($this->sizes !== null) { + $result['sizes'] = $this->sizes; + } + if ($this->theme !== null) { + $result['theme'] = $this->theme; + } + + return $result; + } + + /** + * @return string + */ + public function getSrc(): string + { + return $this->src; + } + + /** + * @return string|null + */ + public function getMimeType(): ?string + { + return $this->mimeType; + } + + /** + * @return array|null + */ + public function getSizes(): ?array + { + return $this->sizes; + } + + /** + * @return 'light'|'dark'|null + */ + public function getTheme(): ?string + { + return $this->theme; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Error.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Error.php new file mode 100644 index 0000000000..b8d7c44797 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Error.php @@ -0,0 +1,126 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * code: int, + * message: string, + * data?: mixed|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['code', 'message']); + + return new self( + self::asInt($data['code']), + self::asString($data['message']), + $data['data'] ?? null + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['code'] = $this->code; + $result['message'] = $this->message; + if ($this->data !== null) { + $result['data'] = $this->data; + } + + return $result; + } + + /** + * @return int + */ + public function getCode(): int + { + return $this->code; + } + + /** + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * @return mixed|null + */ + public function getData() + { + return $this->data; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCErrorResponse.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCErrorResponse.php new file mode 100644 index 0000000000..dfb2df7520 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCErrorResponse.php @@ -0,0 +1,136 @@ +jsonrpc = $jsonrpc; + $this->error = $error; + $this->id = $id; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id?: string|number|null, + * error: array|\WP\McpSchema\Common\JsonRpc\DTO\Error + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'error']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\Error $error */ + $error = is_array($data['error']) + ? Error::fromArray(self::asArray($data['error'])) + : $data['error']; + + /** @var string|number|null $id */ + $id = isset($data['id']) + ? self::asStringOrNumberOrNull($data['id']) + : null; + + return new self( + $jsonrpc, + $error, + $id + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['jsonrpc'] = $this->jsonrpc; + if ($this->id !== null) { + $result['id'] = $this->id; + } + $result['error'] = $this->error->toArray(); + + return $result; + } + + /** + * @return '2.0' + */ + public function getJsonrpc(): string + { + return $this->jsonrpc; + } + + /** + * @return string|number|null + */ + public function getId() + { + return $this->id; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\Error + */ + public function getError(): Error + { + return $this->error; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCNotification.php new file mode 100644 index 0000000000..5cf75c2662 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCNotification.php @@ -0,0 +1,90 @@ +|null $params @since 2024-11-05 + */ + public function __construct( + string $method, + string $jsonrpc, + ?array $params = null + ) { + parent::__construct($method, $params); + $this->jsonrpc = $jsonrpc; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * method: string, + * params?: array|null, + * jsonrpc: '2.0' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['method', 'jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + return new self( + self::asString($data['method']), + $jsonrpc, + self::asArrayOrNull($data['params'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['jsonrpc'] = $this->jsonrpc; + + return $result; + } + + /** + * @return '2.0' + */ + public function getJsonrpc(): string + { + return $this->jsonrpc; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCRequest.php new file mode 100644 index 0000000000..bacd6b5774 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCRequest.php @@ -0,0 +1,114 @@ +|null $params @since 2024-11-05 + */ + public function __construct( + string $method, + string $jsonrpc, + $id, + ?array $params = null + ) { + parent::__construct($method, $params); + $this->jsonrpc = $jsonrpc; + $this->id = $id; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * method: string, + * params?: array|null, + * jsonrpc: '2.0', + * id: string|number + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['method', 'jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + return new self( + self::asString($data['method']), + $jsonrpc, + $id, + self::asArrayOrNull($data['params'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['jsonrpc'] = $this->jsonrpc; + $result['id'] = $this->id; + + return $result; + } + + /** + * @return '2.0' + */ + public function getJsonrpc(): string + { + return $this->jsonrpc; + } + + /** + * @return string|number + */ + public function getId() + { + return $this->id; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCResultResponse.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCResultResponse.php new file mode 100644 index 0000000000..7511a22447 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/JSONRPCResultResponse.php @@ -0,0 +1,133 @@ +jsonrpc = $jsonrpc; + $this->id = $id; + $this->result = $result; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * result: array|\WP\McpSchema\Common\Protocol\DTO\Result + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'result']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Result $result */ + $result = is_array($data['result']) + ? Result::fromArray(self::asArray($data['result'])) + : $data['result']; + + return new self( + $jsonrpc, + $id, + $result + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['jsonrpc'] = $this->jsonrpc; + $result['id'] = $this->id; + $result['result'] = $this->result->toArray(); + + return $result; + } + + /** + * @return '2.0' + */ + public function getJsonrpc(): string + { + return $this->jsonrpc; + } + + /** + * @return string|number + */ + public function getId() + { + return $this->id; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Result + */ + public function getResult(): Result + { + return $this->result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Notification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Notification.php new file mode 100644 index 0000000000..601c8f8dd7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Notification.php @@ -0,0 +1,99 @@ +|null + */ + protected ?array $params; + + /** + * @param string $method @since 2024-11-05 + * @param array|null $params @since 2024-11-05 + */ + public function __construct( + string $method, + ?array $params = null + ) { + $this->method = $method; + $this->params = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * method: string, + * params?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['method']); + + return new self( + self::asString($data['method']), + self::asArrayOrNull($data['params'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['method'] = $this->method; + if ($this->params !== null) { + $result['params'] = $this->params; + } + + return $result; + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * @return array|null + */ + public function getParams(): ?array + { + return $this->params; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/NotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/NotificationParams.php new file mode 100644 index 0000000000..180e337bf7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/NotificationParams.php @@ -0,0 +1,78 @@ +|null + */ + protected ?array $_meta; + + /** + * @param array|null $_meta @since 2025-11-25 + */ + public function __construct( + ?array $_meta = null + ) { + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Request.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Request.php new file mode 100644 index 0000000000..9e0a692650 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/Request.php @@ -0,0 +1,99 @@ +|null + */ + protected ?array $params; + + /** + * @param string $method @since 2024-11-05 + * @param array|null $params @since 2024-11-05 + */ + public function __construct( + string $method, + ?array $params = null + ) { + $this->method = $method; + $this->params = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * method: string, + * params?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['method']); + + return new self( + self::asString($data['method']), + self::asArrayOrNull($data['params'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['method'] = $this->method; + if ($this->params !== null) { + $result['params'] = $this->params; + } + + return $result; + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * @return array|null + */ + public function getParams(): ?array + { + return $this->params; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParams.php new file mode 100644 index 0000000000..709d0da1c6 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParams.php @@ -0,0 +1,87 @@ +_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null + */ + public function get_meta(): ?RequestParamsMeta + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParamsMeta.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParamsMeta.php new file mode 100644 index 0000000000..db83d7d10d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/DTO/RequestParamsMeta.php @@ -0,0 +1,107 @@ + + */ + private const KNOWN_KEYS = ['progressToken']; + + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + * + * @var string|number|null + */ + protected $progressToken; + + /** + * Keys carried on the wire that this type does not model. Preserved verbatim so unrecognized fields survive a round trip. + * + * @var array|null + */ + protected ?array $additionalProperties; + + /** + * @param string|number|null $progressToken + * @param array|null $additionalProperties + */ + public function __construct( + $progressToken = null, + ?array $additionalProperties = null + ) { + $this->progressToken = $progressToken; + $this->additionalProperties = $additionalProperties; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * progressToken?: string|number|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var string|number|null $progressToken */ + $progressToken = isset($data['progressToken']) + ? self::asStringOrNumberOrNull($data['progressToken']) + : null; + + return new self( + $progressToken, + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->progressToken !== null) { + $result['progressToken'] = $this->progressToken; + } + + return $result + ($this->additionalProperties ?? []); + } + + /** + * @return string|number|null + */ + public function getProgressToken() + { + return $this->progressToken; + } + + /** + * @return array|null + */ + public function getAdditionalProperties(): ?array + { + return $this->additionalProperties; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCMessageInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCMessageInterface.php new file mode 100644 index 0000000000..b0fec67661 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCMessageInterface.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCResponseInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCResponseInterface.php new file mode 100644 index 0000000000..5093242db6 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/JsonRpc/Union/JSONRPCResponseInterface.php @@ -0,0 +1,22 @@ +|null + */ + protected ?array $icons; + + /** + * @param string $name @since 2024-11-05 + * @param string $version @since 2024-11-05 + * @param string|null $title @since 2025-06-18 + * @param string|null $description @since 2025-11-25 + * @param string|null $websiteUrl @since 2025-11-25 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $name, + string $version, + ?string $title = null, + ?string $description = null, + ?string $websiteUrl = null, + ?array $icons = null + ) { + parent::__construct($name, $title); + $this->version = $version; + $this->description = $description; + $this->websiteUrl = $websiteUrl; + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * version: string, + * description?: string|null, + * websiteUrl?: string|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name', 'version']); + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['name']), + self::asString($data['version']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['websiteUrl'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['version'] = $this->version; + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->websiteUrl !== null) { + $result['websiteUrl'] = $this->websiteUrl; + } + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return string + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return string|null + */ + public function getWebsiteUrl(): ?string + { + return $this->websiteUrl; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/McpConstants.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/McpConstants.php new file mode 100644 index 0000000000..442841842a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/McpConstants.php @@ -0,0 +1,103 @@ + + */ + public static function getErrorCodeNames(): array + { + return [ + 'PARSE_ERROR' => self::PARSE_ERROR, + 'INVALID_REQUEST' => self::INVALID_REQUEST, + 'METHOD_NOT_FOUND' => self::METHOD_NOT_FOUND, + 'INVALID_PARAMS' => self::INVALID_PARAMS, + 'INTERNAL_ERROR' => self::INTERNAL_ERROR, + 'URL_ELICITATION_REQUIRED' => self::URL_ELICITATION_REQUIRED, + ]; + } + + /** + * Checks if the given error code is valid. + * + * @param int $code + * @return bool + */ + public static function isValidErrorCode(int $code): bool + { + return in_array($code, self::getErrorCodes(), true); + } + + /** + * Gets the constant name for an error code. + * + * @param int $code + * @return string|null The constant name, or null if not found + */ + public static function getErrorCodeName(int $code): ?string + { + $flipped = array_flip(self::getErrorCodeNames()); + return $flipped[$code] ?? null; + } + + /** + * Checks if an error code is a standard JSON-RPC error. + * + * Standard JSON-RPC errors are in the range -32700 to -32600. + * + * @param int $code + * @return bool + */ + public static function isStandardJsonRpcError(int $code): bool + { + return $code >= -32700 && $code <= -32600; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Annotations.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Annotations.php new file mode 100644 index 0000000000..2e49e94cd1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Annotations.php @@ -0,0 +1,147 @@ +|null + */ + protected ?array $audience; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @since 2025-03-26 + * + * @var float|null + */ + protected ?float $priority; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + * + * @since 2025-06-18 + * + * @var string|null + */ + protected ?string $lastModified; + + /** + * @param array<'user'|'assistant'>|null $audience @since 2025-03-26 + * @param float|null $priority @since 2025-03-26 + * @param string|null $lastModified @since 2025-06-18 + */ + public function __construct( + ?array $audience = null, + ?float $priority = null, + ?string $lastModified = null + ) { + $this->audience = $audience; + $this->priority = $priority; + $this->lastModified = $lastModified; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * audience?: array<'user'|'assistant'>|null, + * priority?: float|null, + * lastModified?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var array<'user'|'assistant'>|null $audience */ + $audience = isset($data['audience']) + ? self::asStringArrayOrNull($data['audience']) + : null; + + return new self( + $audience, + self::asFloatOrNull($data['priority'] ?? null), + self::asStringOrNull($data['lastModified'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->audience !== null) { + $result['audience'] = $this->audience; + } + if ($this->priority !== null) { + $result['priority'] = $this->priority; + } + if ($this->lastModified !== null) { + $result['lastModified'] = $this->lastModified; + } + + return $result; + } + + /** + * @return array<'user'|'assistant'>|null + */ + public function getAudience(): ?array + { + return $this->audience; + } + + /** + * @return float|null + */ + public function getPriority(): ?float + { + return $this->priority; + } + + /** + * @return string|null + */ + public function getLastModified(): ?string + { + return $this->lastModified; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BaseMetadata.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BaseMetadata.php new file mode 100644 index 0000000000..63db1f7528 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BaseMetadata.php @@ -0,0 +1,110 @@ +name = $name; + $this->title = $title; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + return new self( + self::asString($data['name']), + self::asStringOrNull($data['title'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['name'] = $this->name; + if ($this->title !== null) { + $result['title'] = $this->title; + } + + return $result; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BlobResourceContents.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BlobResourceContents.php new file mode 100644 index 0000000000..ac3a3cd999 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/BlobResourceContents.php @@ -0,0 +1,92 @@ +|null $_meta @since 2025-06-18 + */ + public function __construct( + string $uri, + string $blob, + ?string $mimeType = null, + ?array $_meta = null + ) { + parent::__construct($uri, $mimeType, $_meta); + $this->blob = $blob; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * uri: string, + * mimeType?: string|null, + * _meta?: array|null, + * blob: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri', 'blob']); + + return new self( + self::asString($data['uri']), + self::asString($data['blob']), + self::asStringOrNull($data['mimeType'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['blob'] = $this->blob; + + return $result; + } + + /** + * @return string + */ + public function getBlob(): string + { + return $this->blob; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotification.php new file mode 100644 index 0000000000..ecd9c80da8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotification.php @@ -0,0 +1,106 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/cancelled', + * params: array|\WP\McpSchema\Common\Protocol\DTO\CancelledNotificationParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\CancelledNotificationParams $params */ + $params = is_array($data['params']) + ? CancelledNotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\CancelledNotificationParams + */ + public function getTypedParams(): CancelledNotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotificationParams.php new file mode 100644 index 0000000000..5ff6b9d41c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/CancelledNotificationParams.php @@ -0,0 +1,119 @@ +|null $_meta @since 2025-11-25 + * @param string|number|null $requestId @since 2025-11-25 + * @param string|null $reason @since 2025-11-25 + */ + public function __construct( + ?array $_meta = null, + $requestId = null, + ?string $reason = null + ) { + parent::__construct($_meta); + $this->requestId = $requestId; + $this->reason = $reason; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * requestId?: string|number|null, + * reason?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var string|number|null $requestId */ + $requestId = isset($data['requestId']) + ? self::asStringOrNumberOrNull($data['requestId']) + : null; + + return new self( + self::asArrayOrNull($data['_meta'] ?? null), + $requestId, + self::asStringOrNull($data['reason'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->requestId !== null) { + $result['requestId'] = $this->requestId; + } + if ($this->reason !== null) { + $result['reason'] = $this->reason; + } + + return $result; + } + + /** + * @return string|number|null + */ + public function getRequestId() + { + return $this->requestId; + } + + /** + * @return string|null + */ + public function getReason(): ?string + { + return $this->reason; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmbeddedResource.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmbeddedResource.php new file mode 100644 index 0000000000..f9a1c3834a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmbeddedResource.php @@ -0,0 +1,166 @@ +|null + */ + protected ?array $_meta; + + /** + * @param \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents $resource @since 2024-11-05 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + $resource, + ?Annotations $annotations = null, + ?array $_meta = null + ) { + $this->type = self::TYPE; + $this->resource = $resource; + $this->annotations = $annotations; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'resource', + * resource: \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['resource']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents $resource */ + $resource = $data['resource']; + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + return new self( + $resource, + $annotations, + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['resource'] = (is_object($this->resource) && method_exists($this->resource, 'toArray')) ? $this->resource->toArray() : $this->resource; + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return 'resource' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents + */ + public function getResource() + { + return $this->resource; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmptyResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmptyResult.php new file mode 100644 index 0000000000..1140243eae --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/EmptyResult.php @@ -0,0 +1,29 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'tasks/result', + * params: array|\WP\McpSchema\Common\Protocol\DTO\GetTaskPayloadRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\GetTaskPayloadRequestParams $params */ + $params = is_array($data['params']) + ? GetTaskPayloadRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\GetTaskPayloadRequestParams + */ + public function getTypedParams(): GetTaskPayloadRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadRequestParams.php new file mode 100644 index 0000000000..fde635c17d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadRequestParams.php @@ -0,0 +1,74 @@ +taskId = $taskId; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskId: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId']); + + return new self( + self::asString($data['taskId']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['taskId'] = $this->taskId; + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadResult.php new file mode 100644 index 0000000000..16ffa33997 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/GetTaskPayloadResult.php @@ -0,0 +1,76 @@ + + */ + private const KNOWN_KEYS = ['_meta']; + + /** + * @param array|null $_meta @since 2025-11-25 + * @param array|null $additionalProperties + */ + public function __construct( + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Icons.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Icons.php new file mode 100644 index 0000000000..8e552700f7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Icons.php @@ -0,0 +1,99 @@ +|null + */ + protected ?array $icons; + + /** + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + ?array $icons = null + ) { + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequest.php new file mode 100644 index 0000000000..625bfdffa3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'initialize', + * params: array|\WP\McpSchema\Common\Protocol\DTO\InitializeRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\InitializeRequestParams $params */ + $params = is_array($data['params']) + ? InitializeRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\InitializeRequestParams + */ + public function getTypedParams(): InitializeRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequestParams.php new file mode 100644 index 0000000000..f59952a37c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeRequestParams.php @@ -0,0 +1,147 @@ +protocolVersion = $protocolVersion; + $this->capabilities = $capabilities; + $this->clientInfo = $clientInfo; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * protocolVersion: string, + * capabilities: array|\WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilities, + * clientInfo: array|\WP\McpSchema\Common\Lifecycle\DTO\Implementation + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['protocolVersion', 'capabilities', 'clientInfo']); + + /** @var \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilities $capabilities */ + $capabilities = is_array($data['capabilities']) + ? ClientCapabilities::fromArray(self::asArray($data['capabilities'])) + : $data['capabilities']; + + /** @var \WP\McpSchema\Common\Lifecycle\DTO\Implementation $clientInfo */ + $clientInfo = is_array($data['clientInfo']) + ? Implementation::fromArray(self::asArray($data['clientInfo'])) + : $data['clientInfo']; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['protocolVersion']), + $capabilities, + $clientInfo, + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['protocolVersion'] = $this->protocolVersion; + $result['capabilities'] = $this->capabilities->toArray(); + $result['clientInfo'] = $this->clientInfo->toArray(); + + return $result; + } + + /** + * @return string + */ + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + /** + * @return \WP\McpSchema\Client\Lifecycle\DTO\ClientCapabilities + */ + public function getCapabilities(): ClientCapabilities + { + return $this->capabilities; + } + + /** + * @return \WP\McpSchema\Common\Lifecycle\DTO\Implementation + */ + public function getClientInfo(): Implementation + { + return $this->clientInfo; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeResult.php new file mode 100644 index 0000000000..552f281d48 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializeResult.php @@ -0,0 +1,177 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'protocolVersion', 'capabilities', 'serverInfo', 'instructions']; + + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + * + * @since 2024-11-05 + * + * @var string + */ + protected string $protocolVersion; + + /** + * @since 2024-11-05 + * + * @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilities + */ + protected ServerCapabilities $capabilities; + + /** + * @since 2024-11-05 + * + * @var \WP\McpSchema\Common\Lifecycle\DTO\Implementation + */ + protected Implementation $serverInfo; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + * + * @since 2024-11-05 + * + * @var string|null + */ + protected ?string $instructions; + + /** + * @param string $protocolVersion @since 2024-11-05 + * @param \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilities $capabilities @since 2024-11-05 + * @param \WP\McpSchema\Common\Lifecycle\DTO\Implementation $serverInfo @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param string|null $instructions @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + string $protocolVersion, + ServerCapabilities $capabilities, + Implementation $serverInfo, + ?array $_meta = null, + ?string $instructions = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->protocolVersion = $protocolVersion; + $this->capabilities = $capabilities; + $this->serverInfo = $serverInfo; + $this->instructions = $instructions; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * protocolVersion: string, + * capabilities: array|\WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilities, + * serverInfo: array|\WP\McpSchema\Common\Lifecycle\DTO\Implementation, + * instructions?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['protocolVersion', 'capabilities', 'serverInfo']); + + /** @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilities $capabilities */ + $capabilities = is_array($data['capabilities']) + ? ServerCapabilities::fromArray(self::asArray($data['capabilities'])) + : $data['capabilities']; + + /** @var \WP\McpSchema\Common\Lifecycle\DTO\Implementation $serverInfo */ + $serverInfo = is_array($data['serverInfo']) + ? Implementation::fromArray(self::asArray($data['serverInfo'])) + : $data['serverInfo']; + + return new self( + self::asString($data['protocolVersion']), + $capabilities, + $serverInfo, + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['instructions'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['protocolVersion'] = $this->protocolVersion; + $result['capabilities'] = $this->capabilities->toArray(); + $result['serverInfo'] = $this->serverInfo->toArray(); + if ($this->instructions !== null) { + $result['instructions'] = $this->instructions; + } + + return $result; + } + + /** + * @return string + */ + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + /** + * @return \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilities + */ + public function getCapabilities(): ServerCapabilities + { + return $this->capabilities; + } + + /** + * @return \WP\McpSchema\Common\Lifecycle\DTO\Implementation + */ + public function getServerInfo(): Implementation + { + return $this->serverInfo; + } + + /** + * @return string|null + */ + public function getInstructions(): ?string + { + return $this->instructions; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializedNotification.php new file mode 100644 index 0000000000..0b9282bcae --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/InitializedNotification.php @@ -0,0 +1,102 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/initialized', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? NotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + */ + public function getTypedParams(): ?NotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequest.php new file mode 100644 index 0000000000..b2f4ef3932 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequest.php @@ -0,0 +1,103 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: string, + * params?: array|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'method']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + self::asString($data['method']), + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null + */ + public function getTypedParams(): ?PaginatedRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequestParams.php new file mode 100644 index 0000000000..1202691c52 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedRequestParams.php @@ -0,0 +1,94 @@ +cursor = $cursor; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * cursor?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + $_meta, + self::asStringOrNull($data['cursor'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->cursor !== null) { + $result['cursor'] = $this->cursor; + } + + return $result; + } + + /** + * @return string|null + */ + public function getCursor(): ?string + { + return $this->cursor; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedResult.php new file mode 100644 index 0000000000..70842d57d5 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PaginatedResult.php @@ -0,0 +1,93 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'nextCursor']; + + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + * + * @since 2024-11-05 + * + * @var string|null + */ + protected ?string $nextCursor; + + /** + * @param array|null $_meta @since 2024-11-05 + * @param string|null $nextCursor @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + ?array $_meta = null, + ?string $nextCursor = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->nextCursor = $nextCursor; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * nextCursor?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['nextCursor'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->nextCursor !== null) { + $result['nextCursor'] = $this->nextCursor; + } + + return $result; + } + + /** + * @return string|null + */ + public function getNextCursor(): ?string + { + return $this->nextCursor; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PingRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PingRequest.php new file mode 100644 index 0000000000..5013a7e2bc --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/PingRequest.php @@ -0,0 +1,110 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'ping', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? RequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\RequestParams|null + */ + public function getTypedParams(): ?RequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotification.php new file mode 100644 index 0000000000..b51ea86ebc --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotification.php @@ -0,0 +1,98 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/progress', + * params: array|\WP\McpSchema\Common\Protocol\DTO\ProgressNotificationParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\ProgressNotificationParams $params */ + $params = is_array($data['params']) + ? ProgressNotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\ProgressNotificationParams + */ + public function getTypedParams(): ProgressNotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotificationParams.php new file mode 100644 index 0000000000..c81dc4cbf8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/ProgressNotificationParams.php @@ -0,0 +1,161 @@ +|null $_meta @since 2025-11-25 + * @param int|null $total @since 2025-11-25 + * @param string|null $message @since 2025-11-25 + */ + public function __construct( + $progressToken, + float $progress, + ?array $_meta = null, + ?int $total = null, + ?string $message = null + ) { + parent::__construct($_meta); + $this->progressToken = $progressToken; + $this->progress = $progress; + $this->total = $total; + $this->message = $message; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * progressToken: string|number, + * progress: float, + * total?: int|null, + * message?: string|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['progressToken', 'progress']); + + /** @var string|number $progressToken */ + $progressToken = self::asStringOrNumber($data['progressToken']); + + return new self( + $progressToken, + self::asFloat($data['progress']), + self::asArrayOrNull($data['_meta'] ?? null), + self::asIntOrNull($data['total'] ?? null), + self::asStringOrNull($data['message'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['progressToken'] = $this->progressToken; + $result['progress'] = $this->progress; + if ($this->total !== null) { + $result['total'] = $this->total; + } + if ($this->message !== null) { + $result['message'] = $this->message; + } + + return $result; + } + + /** + * @return string|number + */ + public function getProgressToken() + { + return $this->progressToken; + } + + /** + * @return float + */ + public function getProgress(): float + { + return $this->progress; + } + + /** + * @return int|null + */ + public function getTotal(): ?int + { + return $this->total; + } + + /** + * @return string|null + */ + public function getMessage(): ?string + { + return $this->message; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Result.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Result.php new file mode 100644 index 0000000000..85ec9fb39e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/Result.php @@ -0,0 +1,104 @@ + + */ + private const KNOWN_KEYS = ['_meta']; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + * + * @since 2024-11-05 + * + * @var array|null + */ + protected ?array $_meta; + + /** + * Keys carried on the wire that this type does not model. Preserved verbatim so unrecognized fields survive a round trip. + * + * @var array|null + */ + protected ?array $additionalProperties; + + /** + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + ?array $_meta = null, + ?array $additionalProperties = null + ) { + $this->_meta = $_meta; + $this->additionalProperties = $additionalProperties; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result + ($this->additionalProperties ?? []); + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } + + /** + * @return array|null + */ + public function getAdditionalProperties(): ?array + { + return $this->additionalProperties; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/TextResourceContents.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/TextResourceContents.php new file mode 100644 index 0000000000..e5218540d8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/TextResourceContents.php @@ -0,0 +1,92 @@ +|null $_meta @since 2025-06-18 + */ + public function __construct( + string $uri, + string $text, + ?string $mimeType = null, + ?array $_meta = null + ) { + parent::__construct($uri, $mimeType, $_meta); + $this->text = $text; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * uri: string, + * mimeType?: string|null, + * _meta?: array|null, + * text: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri', 'text']); + + return new self( + self::asString($data['uri']), + self::asString($data['text']), + self::asStringOrNull($data['mimeType'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['text'] = $this->text; + + return $result; + } + + /** + * @return string + */ + public function getText(): string + { + return $this->text; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/URLElicitationRequiredError.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/URLElicitationRequiredError.php new file mode 100644 index 0000000000..7d9d20c563 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/DTO/URLElicitationRequiredError.php @@ -0,0 +1,78 @@ +error = $error; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * error: mixed + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['error']); + + return new self( + $data['error'] + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['error'] = $this->error; + + return $result; + } + + /** + * @return mixed + */ + public function getError() + { + return $this->error; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Enum/Role.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Enum/Role.php new file mode 100644 index 0000000000..87a37608c3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Enum/Role.php @@ -0,0 +1,43 @@ +> + */ + public const REGISTRY = [ + 'notifications/cancelled' => CancelledNotification::class, + 'notifications/progress' => ProgressNotification::class, + 'notifications/initialized' => InitializedNotification::class, + 'notifications/roots/list_changed' => RootsListChangedNotification::class, + 'notifications/tasks/status' => TaskStatusNotification::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ClientNotificationInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ClientNotificationInterface + { + if (!isset($data['method'])) { + throw new \InvalidArgumentException('Missing discriminator field: method'); + } + + /** @var string $method */ + $method = $data['method']; + if (!isset(self::REGISTRY[$method])) { + throw new \InvalidArgumentException(sprintf( + "Unknown method value '%s'. Valid values: %s", + $method, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$method]; + return $class::fromArray($data); + } + + /** + * Checks if a method value is supported by this factory. + * + * @param string $method + * @return bool + */ + public static function supports(string $method): bool + { + return isset(self::REGISTRY[$method]); + } + + /** + * Returns all supported method values. + * + * @return array + */ + public static function methods(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ClientRequestFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ClientRequestFactory.php new file mode 100644 index 0000000000..33528246c0 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ClientRequestFactory.php @@ -0,0 +1,107 @@ +> + */ + public const REGISTRY = [ + 'ping' => PingRequest::class, + 'initialize' => InitializeRequest::class, + 'completion/complete' => CompleteRequest::class, + 'logging/setLevel' => SetLevelRequest::class, + 'prompts/get' => GetPromptRequest::class, + 'prompts/list' => ListPromptsRequest::class, + 'resources/list' => ListResourcesRequest::class, + 'resources/templates/list' => ListResourceTemplatesRequest::class, + 'resources/read' => ReadResourceRequest::class, + 'resources/subscribe' => SubscribeRequest::class, + 'resources/unsubscribe' => UnsubscribeRequest::class, + 'tools/call' => CallToolRequest::class, + 'tools/list' => ListToolsRequest::class, + 'tasks/get' => GetTaskRequest::class, + 'tasks/result' => GetTaskPayloadRequest::class, + 'tasks/list' => ListTasksRequest::class, + 'tasks/cancel' => CancelTaskRequest::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ClientRequestInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ClientRequestInterface + { + if (!isset($data['method'])) { + throw new \InvalidArgumentException('Missing discriminator field: method'); + } + + /** @var string $method */ + $method = $data['method']; + if (!isset(self::REGISTRY[$method])) { + throw new \InvalidArgumentException(sprintf( + "Unknown method value '%s'. Valid values: %s", + $method, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$method]; + return $class::fromArray($data); + } + + /** + * Checks if a method value is supported by this factory. + * + * @param string $method + * @return bool + */ + public static function supports(string $method): bool + { + return isset(self::REGISTRY[$method]); + } + + /** + * Returns all supported method values. + * + * @return array + */ + public static function methods(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ContentBlockFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ContentBlockFactory.php new file mode 100644 index 0000000000..18c65ef3bc --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ContentBlockFactory.php @@ -0,0 +1,83 @@ +> + */ + public const REGISTRY = [ + 'text' => TextContent::class, + 'image' => ImageContent::class, + 'audio' => AudioContent::class, + 'resource_link' => ResourceLink::class, + 'resource' => EmbeddedResource::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ContentBlockInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ContentBlockInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + /** @var string $type */ + $type = $data['type']; + if (!isset(self::REGISTRY[$type])) { + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + $type, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$type]; + return $class::fromArray($data); + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/SamplingMessageContentBlockFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/SamplingMessageContentBlockFactory.php new file mode 100644 index 0000000000..7af4352d29 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/SamplingMessageContentBlockFactory.php @@ -0,0 +1,83 @@ +> + */ + public const REGISTRY = [ + 'text' => TextContent::class, + 'image' => ImageContent::class, + 'audio' => AudioContent::class, + 'tool_use' => ToolUseContent::class, + 'tool_result' => ToolResultContent::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return SamplingMessageContentBlockInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): SamplingMessageContentBlockInterface + { + if (!isset($data['type'])) { + throw new \InvalidArgumentException('Missing discriminator field: type'); + } + + /** @var string $type */ + $type = $data['type']; + if (!isset(self::REGISTRY[$type])) { + throw new \InvalidArgumentException(sprintf( + "Unknown type value '%s'. Valid values: %s", + $type, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$type]; + return $class::fromArray($data); + } + + /** + * Checks if a type value is supported by this factory. + * + * @param string $type + * @return bool + */ + public static function supports(string $type): bool + { + return isset(self::REGISTRY[$type]); + } + + /** + * Returns all supported type values. + * + * @return array + */ + public static function types(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ServerRequestFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ServerRequestFactory.php new file mode 100644 index 0000000000..95cc53fb3e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Factory/ServerRequestFactory.php @@ -0,0 +1,89 @@ +> + */ + public const REGISTRY = [ + 'ping' => PingRequest::class, + 'sampling/createMessage' => CreateMessageRequest::class, + 'roots/list' => ListRootsRequest::class, + 'elicitation/create' => ElicitRequest::class, + 'tasks/get' => GetTaskRequest::class, + 'tasks/result' => GetTaskPayloadRequest::class, + 'tasks/list' => ListTasksRequest::class, + 'tasks/cancel' => CancelTaskRequest::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ServerRequestInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ServerRequestInterface + { + if (!isset($data['method'])) { + throw new \InvalidArgumentException('Missing discriminator field: method'); + } + + /** @var string $method */ + $method = $data['method']; + if (!isset(self::REGISTRY[$method])) { + throw new \InvalidArgumentException(sprintf( + "Unknown method value '%s'. Valid values: %s", + $method, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$method]; + return $class::fromArray($data); + } + + /** + * Checks if a method value is supported by this factory. + * + * @param string $method + * @return bool + */ + public static function supports(string $method): bool + { + return isset(self::REGISTRY[$method]); + } + + /** + * Returns all supported method values. + * + * @return array + */ + public static function methods(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientNotificationInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientNotificationInterface.php new file mode 100644 index 0000000000..ce2072eaa9 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientNotificationInterface.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientRequestInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientRequestInterface.php new file mode 100644 index 0000000000..a2ea5efca6 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ClientRequestInterface.php @@ -0,0 +1,39 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ContentBlockInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ContentBlockInterface.php new file mode 100644 index 0000000000..38720506b8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ContentBlockInterface.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/SamplingMessageContentBlockInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/SamplingMessageContentBlockInterface.php new file mode 100644 index 0000000000..e41a9776db --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/SamplingMessageContentBlockInterface.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ServerRequestInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ServerRequestInterface.php new file mode 100644 index 0000000000..0b8521af97 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Protocol/Union/ServerRequestInterface.php @@ -0,0 +1,30 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequest.php new file mode 100644 index 0000000000..10ea0c539f --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'tasks/cancel', + * params: array|\WP\McpSchema\Common\Tasks\DTO\CancelTaskRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Tasks\DTO\CancelTaskRequestParams $params */ + $params = is_array($data['params']) + ? CancelTaskRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Tasks\DTO\CancelTaskRequestParams + */ + public function getTypedParams(): CancelTaskRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequestParams.php new file mode 100644 index 0000000000..cad9b809aa --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskRequestParams.php @@ -0,0 +1,74 @@ +taskId = $taskId; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskId: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId']); + + return new self( + self::asString($data['taskId']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['taskId'] = $this->taskId; + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskResult.php new file mode 100644 index 0000000000..ed8257ed7a --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/CancelTaskResult.php @@ -0,0 +1,246 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'taskId', 'status', 'statusMessage', 'createdAt', 'lastUpdatedAt', 'ttl', 'pollInterval']; + + /** + * The task identifier. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $taskId; + + /** + * Current task state. + * + * @since 2025-11-25 + * + * @var 'working'|'input_required'|'completed'|'failed'|'cancelled' + */ + protected string $status; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + * + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $statusMessage = null; + + /** + * ISO 8601 timestamp when the task was created. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $createdAt; + + /** + * ISO 8601 timestamp when the task was last updated. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $lastUpdatedAt; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + * + * @since 2025-11-25 + * + * @var int|null + */ + protected ?int $ttl = null; + + /** + * Suggested polling interval in milliseconds. + * + * @since 2025-11-25 + * + * @var int|null + */ + protected ?int $pollInterval = null; + + /** + * @param string $taskId @since 2025-11-25 + * @param 'working'|'input_required'|'completed'|'failed'|'cancelled' $status @since 2025-11-25 + * @param string $createdAt @since 2025-11-25 + * @param string $lastUpdatedAt @since 2025-11-25 + * @param int $ttl @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + * @param string|null $statusMessage @since 2025-11-25 + * @param int|null $pollInterval @since 2025-11-25 + * @param array|null $additionalProperties @since 2025-11-25 + */ + public function __construct( + string $taskId, + string $status, + string $createdAt, + string $lastUpdatedAt, + int $ttl, + ?array $_meta = null, + ?string $statusMessage = null, + ?int $pollInterval = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->taskId = $taskId; + $this->status = $status; + $this->statusMessage = $statusMessage; + $this->createdAt = $createdAt; + $this->lastUpdatedAt = $lastUpdatedAt; + $this->ttl = $ttl; + $this->pollInterval = $pollInterval; + } + + /** + * Creates an instance from an array. + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId', 'status', 'createdAt', 'lastUpdatedAt', 'ttl']); + + /** @var 'working'|'input_required'|'completed'|'failed'|'cancelled' $status */ + $status = self::asString($data['status']); + + return new self( + self::asString($data['taskId']), + $status, + self::asString($data['createdAt']), + self::asString($data['lastUpdatedAt']), + self::asInt($data['ttl']), + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['statusMessage'] ?? null), + self::asIntOrNull($data['pollInterval'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['taskId'] = $this->taskId; + $result['status'] = $this->status; + if ($this->statusMessage !== null) { + $result['statusMessage'] = $this->statusMessage; + } + $result['createdAt'] = $this->createdAt; + $result['lastUpdatedAt'] = $this->lastUpdatedAt; + $result['ttl'] = $this->ttl; + if ($this->pollInterval !== null) { + $result['pollInterval'] = $this->pollInterval; + } + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } + + /** + * @return 'working'|'input_required'|'completed'|'failed'|'cancelled' + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * @return string|null + */ + public function getStatusMessage(): ?string + { + return $this->statusMessage; + } + + /** + * @return string + */ + public function getCreatedAt(): string + { + return $this->createdAt; + } + + /** + * @return string + */ + public function getLastUpdatedAt(): string + { + return $this->lastUpdatedAt; + } + + /** + * @return int|null + */ + public function getTtl(): ?int + { + return $this->ttl; + } + + /** + * @return int|null + */ + public function getPollInterval(): ?int + { + return $this->pollInterval; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequest.php new file mode 100644 index 0000000000..728532ef43 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'tasks/get', + * params: array|\WP\McpSchema\Common\Tasks\DTO\GetTaskRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Tasks\DTO\GetTaskRequestParams $params */ + $params = is_array($data['params']) + ? GetTaskRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Common\Tasks\DTO\GetTaskRequestParams + */ + public function getTypedParams(): GetTaskRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequestParams.php new file mode 100644 index 0000000000..7bdcfb0044 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskRequestParams.php @@ -0,0 +1,74 @@ +taskId = $taskId; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskId: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId']); + + return new self( + self::asString($data['taskId']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['taskId'] = $this->taskId; + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskResult.php new file mode 100644 index 0000000000..1eb8f3742c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/GetTaskResult.php @@ -0,0 +1,246 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'taskId', 'status', 'statusMessage', 'createdAt', 'lastUpdatedAt', 'ttl', 'pollInterval']; + + /** + * The task identifier. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $taskId; + + /** + * Current task state. + * + * @since 2025-11-25 + * + * @var 'working'|'input_required'|'completed'|'failed'|'cancelled' + */ + protected string $status; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + * + * @since 2025-11-25 + * + * @var string|null + */ + protected ?string $statusMessage = null; + + /** + * ISO 8601 timestamp when the task was created. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $createdAt; + + /** + * ISO 8601 timestamp when the task was last updated. + * + * @since 2025-11-25 + * + * @var string + */ + protected string $lastUpdatedAt; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + * + * @since 2025-11-25 + * + * @var int|null + */ + protected ?int $ttl = null; + + /** + * Suggested polling interval in milliseconds. + * + * @since 2025-11-25 + * + * @var int|null + */ + protected ?int $pollInterval = null; + + /** + * @param string $taskId @since 2025-11-25 + * @param 'working'|'input_required'|'completed'|'failed'|'cancelled' $status @since 2025-11-25 + * @param string $createdAt @since 2025-11-25 + * @param string $lastUpdatedAt @since 2025-11-25 + * @param int $ttl @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + * @param string|null $statusMessage @since 2025-11-25 + * @param int|null $pollInterval @since 2025-11-25 + * @param array|null $additionalProperties @since 2025-11-25 + */ + public function __construct( + string $taskId, + string $status, + string $createdAt, + string $lastUpdatedAt, + int $ttl, + ?array $_meta = null, + ?string $statusMessage = null, + ?int $pollInterval = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->taskId = $taskId; + $this->status = $status; + $this->statusMessage = $statusMessage; + $this->createdAt = $createdAt; + $this->lastUpdatedAt = $lastUpdatedAt; + $this->ttl = $ttl; + $this->pollInterval = $pollInterval; + } + + /** + * Creates an instance from an array. + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['taskId', 'status', 'createdAt', 'lastUpdatedAt', 'ttl']); + + /** @var 'working'|'input_required'|'completed'|'failed'|'cancelled' $status */ + $status = self::asString($data['status']); + + return new self( + self::asString($data['taskId']), + $status, + self::asString($data['createdAt']), + self::asString($data['lastUpdatedAt']), + self::asInt($data['ttl']), + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['statusMessage'] ?? null), + self::asIntOrNull($data['pollInterval'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['taskId'] = $this->taskId; + $result['status'] = $this->status; + if ($this->statusMessage !== null) { + $result['statusMessage'] = $this->statusMessage; + } + $result['createdAt'] = $this->createdAt; + $result['lastUpdatedAt'] = $this->lastUpdatedAt; + $result['ttl'] = $this->ttl; + if ($this->pollInterval !== null) { + $result['pollInterval'] = $this->pollInterval; + } + + return $result; + } + + /** + * @return string + */ + public function getTaskId(): string + { + return $this->taskId; + } + + /** + * @return 'working'|'input_required'|'completed'|'failed'|'cancelled' + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * @return string|null + */ + public function getStatusMessage(): ?string + { + return $this->statusMessage; + } + + /** + * @return string + */ + public function getCreatedAt(): string + { + return $this->createdAt; + } + + /** + * @return string + */ + public function getLastUpdatedAt(): string + { + return $this->lastUpdatedAt; + } + + /** + * @return int|null + */ + public function getTtl(): ?int + { + return $this->ttl; + } + + /** + * @return int|null + */ + public function getPollInterval(): ?int + { + return $this->pollInterval; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksRequest.php new file mode 100644 index 0000000000..189981e577 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksRequest.php @@ -0,0 +1,95 @@ +|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null, + * jsonrpc: '2.0', + * id: string|number, + * method: 'tasks/list' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksResult.php new file mode 100644 index 0000000000..2681776fd8 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/ListTasksResult.php @@ -0,0 +1,108 @@ + + */ + private const KNOWN_KEYS = ['nextCursor', '_meta', 'tasks']; + + /** + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Client\Tasks\DTO\Task> + */ + protected array $tasks; + + /** + * @param array<\WP\McpSchema\Client\Tasks\DTO\Task> $tasks @since 2025-11-25 + * @param string|null $nextCursor @since 2025-11-25 + * @param array|null $_meta @since 2025-11-25 + * @param array|null $additionalProperties + */ + public function __construct( + array $tasks, + ?string $nextCursor = null, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $nextCursor, $additionalProperties); + $this->tasks = $tasks; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * nextCursor?: string|null, + * _meta?: array|null, + * tasks: array|\WP\McpSchema\Client\Tasks\DTO\Task> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['tasks']); + + /** @var array<\WP\McpSchema\Client\Tasks\DTO\Task> $tasks */ + $tasks = array_map( + static fn($item) => is_array($item) + ? Task::fromArray($item) + : $item, + self::asArray($data['tasks']) + ); + + return new self( + $tasks, + self::asStringOrNull($data['nextCursor'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['tasks'] = array_map(static fn($item) => $item->toArray(), $this->tasks); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Client\Tasks\DTO\Task> + */ + public function getTasks(): array + { + return $this->tasks; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskAugmentedRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskAugmentedRequestParams.php new file mode 100644 index 0000000000..5e121b86d9 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskAugmentedRequestParams.php @@ -0,0 +1,106 @@ +task = $task; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * task?: array|\WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + /** @var \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task */ + $task = isset($data['task']) + ? (is_array($data['task']) + ? TaskMetadata::fromArray(self::asArray($data['task'])) + : $data['task']) + : null; + + return new self( + $_meta, + $task + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->task !== null) { + $result['task'] = $this->task->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null + */ + public function getTask(): ?TaskMetadata + { + return $this->task; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskStatusNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskStatusNotification.php new file mode 100644 index 0000000000..39afbb6461 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Tasks/DTO/TaskStatusNotification.php @@ -0,0 +1,92 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/tasks/status', + * params: mixed + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + return new self( + $jsonrpc, + $data['params'] + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams; + + return $result; + } + + /** + * @return mixed + */ + public function getTypedParams() + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Common/Traits/ValidatesRequiredFields.php b/lib/vendor/wordpress/php-mcp-schema/src/Common/Traits/ValidatesRequiredFields.php new file mode 100644 index 0000000000..5e6c5fabab --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Common/Traits/ValidatesRequiredFields.php @@ -0,0 +1,412 @@ + $data The input data array + * @param string[] $requiredFields List of required field names + * @return void + * @throws \InvalidArgumentException If any required fields are missing + */ + protected static function assertRequired(array $data, array $requiredFields): void + { + $missing = array_filter( + $requiredFields, + static fn(string $field): bool => !array_key_exists($field, $data) + ); + + if (count($missing) > 0) { + throw new \InvalidArgumentException(sprintf( + '%s: missing required field(s): %s', + static::class, + implode(', ', $missing) + )); + } + } + + /** + * Asserts a value is a string and returns it. + * + * @param mixed $value + * @return string + * @phpstan-assert string $value + */ + protected static function asString($value): string + { + if (!is_string($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected string, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Asserts a value is an int and returns it. + * + * @param mixed $value + * @return int + * @phpstan-assert int $value + */ + protected static function asInt($value): int + { + if (!is_int($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected int, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Asserts a value is a float and returns it. + * + * @param mixed $value + * @return float + * @phpstan-assert float $value + */ + protected static function asFloat($value): float + { + if (!is_float($value) && !is_int($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected float, got %s', + gettype($value) + )); + } + return (float) $value; + } + + /** + * Asserts a value is a bool and returns it. + * + * @param mixed $value + * @return bool + * @phpstan-assert bool $value + */ + protected static function asBool($value): bool + { + if (!is_bool($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected bool, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Asserts a value is an array and returns it. + * + * @param mixed $value + * @return array + * @phpstan-assert array $value + */ + protected static function asArray($value): array + { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array, got %s', + gettype($value) + )); + } + /** @var array */ + return $value; + } + + /** + * Returns a value as string or null. + * + * @param mixed $value + * @return string|null + */ + protected static function asStringOrNull($value): ?string + { + return $value === null ? null : self::asString($value); + } + + /** + * Returns a value as int or null. + * + * @param mixed $value + * @return int|null + */ + protected static function asIntOrNull($value): ?int + { + return $value === null ? null : self::asInt($value); + } + + /** + * Returns a value as float or null. + * + * @param mixed $value + * @return float|null + */ + protected static function asFloatOrNull($value): ?float + { + return $value === null ? null : self::asFloat($value); + } + + /** + * Returns a value as bool or null. + * + * @param mixed $value + * @return bool|null + */ + protected static function asBoolOrNull($value): ?bool + { + return $value === null ? null : self::asBool($value); + } + + /** + * Returns a value as array or null. + * + * @param mixed $value + * @return array|null + */ + protected static function asArrayOrNull($value): ?array + { + return $value === null ? null : self::asArray($value); + } + + /** + * Returns the entries of $data whose keys the caller does not model. + * + * Used by types the MCP schema declares open (`[key: string]: unknown`), + * so that unrecognized fields survive a fromArray()/toArray() round trip + * instead of being silently discarded. + * + * @param array $data + * @param array $known + * @return array|null + */ + protected static function additionalFields(array $data, array $known): ?array + { + $additional = array_diff_key($data, array_flip($known)); + + return $additional === [] ? null : $additional; + } + + /** + * Asserts a value is an object and returns it. + * + * Accepts both PHP arrays and objects, auto-converting arrays to objects. + * This aligns with MCP spec where JSON objects can be PHP arrays or objects. + * + * @param mixed $value + * @return object + * @phpstan-assert object $value + */ + protected static function asObject($value): object + { + if (is_array($value)) { + return (object) $value; + } + if (!is_object($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array or object, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Returns a value as object or null. + * + * @param mixed $value + * @return object|null + */ + protected static function asObjectOrNull($value): ?object + { + return $value === null ? null : self::asObject($value); + } + + /** + * Asserts a value is an array of strings and returns it. + * + * @param mixed $value + * @return array + */ + protected static function asStringArray($value): array + { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array, got %s', + gettype($value) + )); + } + /** @var array */ + return array_values(array_map(static fn($item): string => (string) $item, $value)); + } + + /** + * Returns a value as array of strings or null. + * + * @param mixed $value + * @return array|null + */ + protected static function asStringArrayOrNull($value): ?array + { + return $value === null ? null : self::asStringArray($value); + } + + /** + * Asserts a value is an associative array with string values only. + * + * Used for MCP types like { [key: string]: string } index signatures. + * + * @param mixed $value + * @return array + * @phpstan-assert array $value + */ + protected static function asStringMap($value): array + { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array, got %s', + gettype($value) + )); + } + foreach ($value as $key => $v) { + if (!is_string($v)) { + throw new \InvalidArgumentException(sprintf( + 'Expected string value for key "%s", got %s', + (string) $key, + gettype($v) + )); + } + } + /** @var array */ + return $value; + } + + /** + * Returns a value as string map or null. + * + * Used for optional MCP types like { [key: string]: string } | null. + * + * @param mixed $value + * @return array|null + */ + protected static function asStringMapOrNull($value): ?array + { + return $value === null ? null : self::asStringMap($value); + } + + /** + * Asserts a value is an associative array with object values only. + * + * Used for MCP types like { [key: string]: object } index signatures. + * Accepts both PHP arrays and objects as values, auto-converting arrays to objects. + * This aligns with MCP spec where JSON objects can be PHP arrays or objects. + * + * @param mixed $value + * @return array + * @phpstan-assert array $value + */ + protected static function asObjectMap($value): array + { + if (!is_array($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array, got %s', + gettype($value) + )); + } + + $result = []; + foreach ($value as $key => $v) { + if (is_array($v)) { + $result[$key] = (object) $v; + } elseif (is_object($v)) { + $result[$key] = $v; + } else { + throw new \InvalidArgumentException(sprintf( + 'Expected array or object for key "%s", got %s', + (string) $key, + gettype($v) + )); + } + } + + /** @var array */ + return $result; + } + + /** + * Returns a value as object map or null. + * + * Used for optional MCP types like { [key: string]: object } | null. + * + * @param mixed $value + * @return array|null + */ + protected static function asObjectMapOrNull($value): ?array + { + return $value === null ? null : self::asObjectMap($value); + } + + /** + * Asserts a value is a scalar (string, int, float, or bool) for sprintf. + * + * @param mixed $value + * @return string|int|float|bool + */ + protected static function asScalar($value) + { + if (!is_scalar($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected scalar value, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Asserts a value is a string or number (int/float) and returns it. + * + * Used for MCP types like ProgressToken that accept string | number. + * + * @param mixed $value + * @return string|int|float + */ + protected static function asStringOrNumber($value) + { + if (!is_string($value) && !is_int($value) && !is_float($value)) { + throw new \InvalidArgumentException(sprintf( + 'Expected string or number, got %s', + gettype($value) + )); + } + return $value; + } + + /** + * Returns a value as string or number (int/float), or null. + * + * Used for optional MCP types like ProgressToken that accept string | number | null. + * + * @param mixed $value + * @return string|int|float|null + */ + protected static function asStringOrNumberOrNull($value) + { + return $value === null ? null : self::asStringOrNumber($value); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequest.php new file mode 100644 index 0000000000..4a15bf9e2c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'completion/complete', + * params: array|\WP\McpSchema\Server\Core\DTO\CompleteRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Core\DTO\CompleteRequestParams $params */ + $params = is_array($data['params']) + ? CompleteRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Core\DTO\CompleteRequestParams + */ + public function getTypedParams(): CompleteRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParams.php new file mode 100644 index 0000000000..3b0c3311a0 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParams.php @@ -0,0 +1,154 @@ +ref = $ref; + $this->argument = $argument; + $this->context = $context; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * ref: \WP\McpSchema\Server\Core\DTO\PromptReference|\WP\McpSchema\Server\Core\DTO\ResourceTemplateReference, + * argument: array|\WP\McpSchema\Server\Core\DTO\CompleteRequestParamsArgument, + * context?: array|\WP\McpSchema\Server\Core\DTO\CompleteRequestParamsContext|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['ref', 'argument']); + + /** @var \WP\McpSchema\Server\Core\DTO\PromptReference|\WP\McpSchema\Server\Core\DTO\ResourceTemplateReference $ref */ + $ref = $data['ref']; + + /** @var \WP\McpSchema\Server\Core\DTO\CompleteRequestParamsArgument $argument */ + $argument = is_array($data['argument']) + ? CompleteRequestParamsArgument::fromArray(self::asArray($data['argument'])) + : $data['argument']; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + /** @var \WP\McpSchema\Server\Core\DTO\CompleteRequestParamsContext|null $context */ + $context = isset($data['context']) + ? (is_array($data['context']) + ? CompleteRequestParamsContext::fromArray(self::asArray($data['context'])) + : $data['context']) + : null; + + return new self( + $ref, + $argument, + $_meta, + $context + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['ref'] = (is_object($this->ref) && method_exists($this->ref, 'toArray')) ? $this->ref->toArray() : $this->ref; + $result['argument'] = $this->argument->toArray(); + if ($this->context !== null) { + $result['context'] = $this->context->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Core\DTO\PromptReference|\WP\McpSchema\Server\Core\DTO\ResourceTemplateReference + */ + public function getRef() + { + return $this->ref; + } + + /** + * @return \WP\McpSchema\Server\Core\DTO\CompleteRequestParamsArgument + */ + public function getArgument(): CompleteRequestParamsArgument + { + return $this->argument; + } + + /** + * @return \WP\McpSchema\Server\Core\DTO\CompleteRequestParamsContext|null + */ + public function getContext(): ?CompleteRequestParamsContext + { + return $this->context; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsArgument.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsArgument.php new file mode 100644 index 0000000000..b2508ae220 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsArgument.php @@ -0,0 +1,97 @@ +name = $name; + $this->value = $value; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * value: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name', 'value']); + + return new self( + self::asString($data['name']), + self::asString($data['value']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['name'] = $this->name; + $result['value'] = $this->value; + + return $result; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsContext.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsContext.php new file mode 100644 index 0000000000..91159fa885 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteRequestParamsContext.php @@ -0,0 +1,76 @@ +|null + */ + protected ?array $arguments; + + /** + * @param array|null $arguments + */ + public function __construct( + ?array $arguments = null + ) { + $this->arguments = $arguments; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * arguments?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringMapOrNull($data['arguments'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->arguments !== null) { + $result['arguments'] = $this->arguments; + } + + return $result; + } + + /** + * @return array|null + */ + public function getArguments(): ?array + { + return $this->arguments; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResult.php new file mode 100644 index 0000000000..3dd39f3faa --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResult.php @@ -0,0 +1,99 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'completion']; + + /** + * @since 2024-11-05 + * + * @var \WP\McpSchema\Server\Core\DTO\CompleteResultCompletion + */ + protected CompleteResultCompletion $completion; + + /** + * @param \WP\McpSchema\Server\Core\DTO\CompleteResultCompletion $completion @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + CompleteResultCompletion $completion, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->completion = $completion; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * completion: array|\WP\McpSchema\Server\Core\DTO\CompleteResultCompletion + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['completion']); + + /** @var \WP\McpSchema\Server\Core\DTO\CompleteResultCompletion $completion */ + $completion = is_array($data['completion']) + ? CompleteResultCompletion::fromArray(self::asArray($data['completion'])) + : $data['completion']; + + return new self( + $completion, + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['completion'] = $this->completion->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Core\DTO\CompleteResultCompletion + */ + public function getCompletion(): CompleteResultCompletion + { + return $this->completion; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResultCompletion.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResultCompletion.php new file mode 100644 index 0000000000..aa711d629d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/CompleteResultCompletion.php @@ -0,0 +1,132 @@ + + */ + protected array $values; + + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + * + * @var int|null + */ + protected ?int $total; + + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + * + * @var bool|null + */ + protected ?bool $hasMore; + + /** + * @param array $values + * @param int|null $total + * @param bool|null $hasMore + */ + public function __construct( + array $values, + ?int $total = null, + ?bool $hasMore = null + ) { + $this->values = $values; + $this->total = $total; + $this->hasMore = $hasMore; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * values: array, + * total?: int|null, + * hasMore?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['values']); + + if (is_array($data['values']) && count($data['values']) > self::MAX_VALUES) { + throw new \InvalidArgumentException(sprintf( + '%s::values must not exceed %d items, got %d', + static::class, + self::MAX_VALUES, + count($data['values']) + )); + } + + return new self( + self::asStringArray($data['values']), + self::asIntOrNull($data['total'] ?? null), + self::asBoolOrNull($data['hasMore'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['values'] = $this->values; + if ($this->total !== null) { + $result['total'] = $this->total; + } + if ($this->hasMore !== null) { + $result['hasMore'] = $this->hasMore; + } + + return $result; + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + /** + * @return int|null + */ + public function getTotal(): ?int + { + return $this->total; + } + + /** + * @return bool|null + */ + public function getHasMore(): ?bool + { + return $this->hasMore; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/PromptReference.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/PromptReference.php new file mode 100644 index 0000000000..c7a5b76eb9 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/PromptReference.php @@ -0,0 +1,87 @@ +type = self::TYPE; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * type: 'ref/prompt' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + return new self( + self::asString($data['name']), + self::asStringOrNull($data['title'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['type'] = $this->type; + + return $result; + } + + /** + * @return 'ref/prompt' + */ + public function getType(): string + { + return $this->type; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/ResourceTemplateReference.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/ResourceTemplateReference.php new file mode 100644 index 0000000000..b4cefe2fe9 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Core/DTO/ResourceTemplateReference.php @@ -0,0 +1,100 @@ +type = self::TYPE; + $this->uri = $uri; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * type: 'ref/resource', + * uri: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + return new self( + self::asString($data['uri']) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['type'] = $this->type; + $result['uri'] = $this->uri; + + return $result; + } + + /** + * @return 'ref/resource' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilities.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilities.php new file mode 100644 index 0000000000..28e9625426 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilities.php @@ -0,0 +1,259 @@ +|null + */ + protected ?array $experimental; + + /** + * Present if the server supports sending log messages to the client. + * + * @since 2024-11-05 + * + * @var object|null + */ + protected ?object $logging; + + /** + * Present if the server supports argument autocompletion suggestions. + * + * @since 2025-03-26 + * + * @var object|null + */ + protected ?object $completions; + + /** + * Present if the server offers any prompt templates. + * + * @since 2024-11-05 + * + * @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesPrompts|null + */ + protected ?ServerCapabilitiesPrompts $prompts; + + /** + * Present if the server offers any resources to read. + * + * @since 2024-11-05 + * + * @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesResources|null + */ + protected ?ServerCapabilitiesResources $resources; + + /** + * Present if the server offers any tools to call. + * + * @since 2024-11-05 + * + * @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTools|null + */ + protected ?ServerCapabilitiesTools $tools; + + /** + * Present if the server supports task-augmented requests. + * + * @since 2025-11-25 + * + * @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTasks|null + */ + protected ?ServerCapabilitiesTasks $tasks; + + /** + * @param array|null $experimental @since 2024-11-05 + * @param object|null $logging @since 2024-11-05 + * @param object|null $completions @since 2025-03-26 + * @param \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesPrompts|null $prompts @since 2024-11-05 + * @param \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesResources|null $resources @since 2024-11-05 + * @param \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTools|null $tools @since 2024-11-05 + * @param \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTasks|null $tasks @since 2025-11-25 + */ + public function __construct( + ?array $experimental = null, + ?object $logging = null, + ?object $completions = null, + ?ServerCapabilitiesPrompts $prompts = null, + ?ServerCapabilitiesResources $resources = null, + ?ServerCapabilitiesTools $tools = null, + ?ServerCapabilitiesTasks $tasks = null + ) { + $this->experimental = $experimental; + $this->logging = $logging; + $this->completions = $completions; + $this->prompts = $prompts; + $this->resources = $resources; + $this->tools = $tools; + $this->tasks = $tasks; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * experimental?: array|null, + * logging?: object|null, + * completions?: object|null, + * prompts?: array|\WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesPrompts|null, + * resources?: array|\WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesResources|null, + * tools?: array|\WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTools|null, + * tasks?: array|\WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTasks|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesPrompts|null $prompts */ + $prompts = isset($data['prompts']) + ? (is_array($data['prompts']) + ? ServerCapabilitiesPrompts::fromArray(self::asArray($data['prompts'])) + : $data['prompts']) + : null; + + /** @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesResources|null $resources */ + $resources = isset($data['resources']) + ? (is_array($data['resources']) + ? ServerCapabilitiesResources::fromArray(self::asArray($data['resources'])) + : $data['resources']) + : null; + + /** @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTools|null $tools */ + $tools = isset($data['tools']) + ? (is_array($data['tools']) + ? ServerCapabilitiesTools::fromArray(self::asArray($data['tools'])) + : $data['tools']) + : null; + + /** @var \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTasks|null $tasks */ + $tasks = isset($data['tasks']) + ? (is_array($data['tasks']) + ? ServerCapabilitiesTasks::fromArray(self::asArray($data['tasks'])) + : $data['tasks']) + : null; + + return new self( + self::asObjectMapOrNull($data['experimental'] ?? null), + self::asObjectOrNull($data['logging'] ?? null), + self::asObjectOrNull($data['completions'] ?? null), + $prompts, + $resources, + $tools, + $tasks + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->experimental !== null) { + $result['experimental'] = $this->experimental; + } + if ($this->logging !== null) { + $result['logging'] = $this->logging; + } + if ($this->completions !== null) { + $result['completions'] = $this->completions; + } + if ($this->prompts !== null) { + $result['prompts'] = $this->prompts->toArray(); + } + if ($this->resources !== null) { + $result['resources'] = $this->resources->toArray(); + } + if ($this->tools !== null) { + $result['tools'] = $this->tools->toArray(); + } + if ($this->tasks !== null) { + $result['tasks'] = $this->tasks->toArray(); + } + + return $result; + } + + /** + * @return array|null + */ + public function getExperimental(): ?array + { + return $this->experimental; + } + + /** + * @return object|null + */ + public function getLogging(): ?object + { + return $this->logging; + } + + /** + * @return object|null + */ + public function getCompletions(): ?object + { + return $this->completions; + } + + /** + * @return \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesPrompts|null + */ + public function getPrompts(): ?ServerCapabilitiesPrompts + { + return $this->prompts; + } + + /** + * @return \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesResources|null + */ + public function getResources(): ?ServerCapabilitiesResources + { + return $this->resources; + } + + /** + * @return \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTools|null + */ + public function getTools(): ?ServerCapabilitiesTools + { + return $this->tools; + } + + /** + * @return \WP\McpSchema\Server\Lifecycle\DTO\ServerCapabilitiesTasks|null + */ + public function getTasks(): ?ServerCapabilitiesTasks + { + return $this->tasks; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesPrompts.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesPrompts.php new file mode 100644 index 0000000000..8e1d2295e0 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesPrompts.php @@ -0,0 +1,76 @@ +listChanged = $listChanged; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * listChanged?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asBoolOrNull($data['listChanged'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->listChanged !== null) { + $result['listChanged'] = $this->listChanged; + } + + return $result; + } + + /** + * @return bool|null + */ + public function getListChanged(): ?bool + { + return $this->listChanged; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesResources.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesResources.php new file mode 100644 index 0000000000..a69990ca88 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesResources.php @@ -0,0 +1,99 @@ +subscribe = $subscribe; + $this->listChanged = $listChanged; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * subscribe?: bool|null, + * listChanged?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asBoolOrNull($data['subscribe'] ?? null), + self::asBoolOrNull($data['listChanged'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->subscribe !== null) { + $result['subscribe'] = $this->subscribe; + } + if ($this->listChanged !== null) { + $result['listChanged'] = $this->listChanged; + } + + return $result; + } + + /** + * @return bool|null + */ + public function getSubscribe(): ?bool + { + return $this->subscribe; + } + + /** + * @return bool|null + */ + public function getListChanged(): ?bool + { + return $this->listChanged; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTasks.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTasks.php new file mode 100644 index 0000000000..a3e83d55a3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTasks.php @@ -0,0 +1,99 @@ +list = $list; + $this->cancel = $cancel; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * list?: object|null, + * cancel?: object|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asObjectOrNull($data['list'] ?? null), + self::asObjectOrNull($data['cancel'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->list !== null) { + $result['list'] = $this->list; + } + if ($this->cancel !== null) { + $result['cancel'] = $this->cancel; + } + + return $result; + } + + /** + * @return object|null + */ + public function getList(): ?object + { + return $this->list; + } + + /** + * @return object|null + */ + public function getCancel(): ?object + { + return $this->cancel; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTools.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTools.php new file mode 100644 index 0000000000..09d258835b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/DTO/ServerCapabilitiesTools.php @@ -0,0 +1,76 @@ +listChanged = $listChanged; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * listChanged?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asBoolOrNull($data['listChanged'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->listChanged !== null) { + $result['listChanged'] = $this->listChanged; + } + + return $result; + } + + /** + * @return bool|null + */ + public function getListChanged(): ?bool + { + return $this->listChanged; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Factory/ServerNotificationFactory.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Factory/ServerNotificationFactory.php new file mode 100644 index 0000000000..ef1612f6b3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Factory/ServerNotificationFactory.php @@ -0,0 +1,91 @@ +> + */ + public const REGISTRY = [ + 'notifications/cancelled' => CancelledNotification::class, + 'notifications/progress' => ProgressNotification::class, + 'notifications/message' => LoggingMessageNotification::class, + 'notifications/resources/updated' => ResourceUpdatedNotification::class, + 'notifications/resources/list_changed' => ResourceListChangedNotification::class, + 'notifications/tools/list_changed' => ToolListChangedNotification::class, + 'notifications/prompts/list_changed' => PromptListChangedNotification::class, + 'notifications/elicitation/complete' => ElicitationCompleteNotification::class, + 'notifications/tasks/status' => TaskStatusNotification::class, + ]; + + /** + * Creates an instance from an array. + * + * @param array $data + * @return ServerNotificationInterface + * @throws \InvalidArgumentException + */ + public static function fromArray(array $data): ServerNotificationInterface + { + if (!isset($data['method'])) { + throw new \InvalidArgumentException('Missing discriminator field: method'); + } + + /** @var string $method */ + $method = $data['method']; + if (!isset(self::REGISTRY[$method])) { + throw new \InvalidArgumentException(sprintf( + "Unknown method value '%s'. Valid values: %s", + $method, + implode(', ', array_keys(self::REGISTRY)) + )); + } + + $class = self::REGISTRY[$method]; + return $class::fromArray($data); + } + + /** + * Checks if a method value is supported by this factory. + * + * @param string $method + * @return bool + */ + public static function supports(string $method): bool + { + return isset(self::REGISTRY[$method]); + } + + /** + * Returns all supported method values. + * + * @return array + */ + public static function methods(): array + { + return array_keys(self::REGISTRY); + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerNotificationInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerNotificationInterface.php new file mode 100644 index 0000000000..e34902342b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerNotificationInterface.php @@ -0,0 +1,31 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerResultInterface.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerResultInterface.php new file mode 100644 index 0000000000..5f4df913e1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Lifecycle/Union/ServerResultInterface.php @@ -0,0 +1,36 @@ + + */ + public function toArray(): array; +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotification.php new file mode 100644 index 0000000000..abb699200b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotification.php @@ -0,0 +1,97 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/message', + * params: array|\WP\McpSchema\Server\Logging\DTO\LoggingMessageNotificationParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Server\Logging\DTO\LoggingMessageNotificationParams $params */ + $params = is_array($data['params']) + ? LoggingMessageNotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Logging\DTO\LoggingMessageNotificationParams + */ + public function getTypedParams(): LoggingMessageNotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotificationParams.php new file mode 100644 index 0000000000..71b0a13bd7 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/LoggingMessageNotificationParams.php @@ -0,0 +1,136 @@ +|null $_meta @since 2025-11-25 + * @param string|null $logger @since 2025-11-25 + */ + public function __construct( + string $level, + $data, + ?array $_meta = null, + ?string $logger = null + ) { + parent::__construct($_meta); + $this->level = $level; + $this->data = $data; + $this->logger = $logger; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * level: 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency', + * logger?: string|null, + * data: mixed + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['level', 'data']); + + /** @var 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency' $level */ + $level = self::asString($data['level']); + + return new self( + $level, + $data['data'], + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['logger'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['level'] = $this->level; + if ($this->logger !== null) { + $result['logger'] = $this->logger; + } + $result['data'] = $this->data; + + return $result; + } + + /** + * @return 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency' + */ + public function getLevel(): string + { + return $this->level; + } + + /** + * @return string|null + */ + public function getLogger(): ?string + { + return $this->logger; + } + + /** + * @return mixed + */ + public function getData() + { + return $this->data; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequest.php new file mode 100644 index 0000000000..cd6bd9c9de --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'logging/setLevel', + * params: array|\WP\McpSchema\Server\Logging\DTO\SetLevelRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Logging\DTO\SetLevelRequestParams $params */ + $params = is_array($data['params']) + ? SetLevelRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Logging\DTO\SetLevelRequestParams + */ + public function getTypedParams(): SetLevelRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequestParams.php new file mode 100644 index 0000000000..6d30229855 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/DTO/SetLevelRequestParams.php @@ -0,0 +1,96 @@ +level = $level; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * level: 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['level']); + + /** @var 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency' $level */ + $level = self::asString($data['level']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + $level, + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['level'] = $this->level; + + return $result; + } + + /** + * @return 'debug'|'info'|'notice'|'warning'|'error'|'critical'|'alert'|'emergency' + */ + public function getLevel(): string + { + return $this->level; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/Enum/LoggingLevel.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/Enum/LoggingLevel.php new file mode 100644 index 0000000000..f1df99e395 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Logging/Enum/LoggingLevel.php @@ -0,0 +1,88 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'prompts/get', + * params: array|\WP\McpSchema\Server\Prompts\DTO\GetPromptRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Prompts\DTO\GetPromptRequestParams $params */ + $params = is_array($data['params']) + ? GetPromptRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptRequestParams + */ + public function getTypedParams(): GetPromptRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptRequestParams.php new file mode 100644 index 0000000000..7743bce740 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptRequestParams.php @@ -0,0 +1,118 @@ +|null + */ + protected ?array $arguments; + + /** + * @param string $name @since 2025-11-25 + * @param \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta @since 2025-11-25 + * @param array|null $arguments @since 2025-11-25 + */ + public function __construct( + string $name, + ?RequestParamsMeta $_meta = null, + ?array $arguments = null + ) { + parent::__construct($_meta); + $this->name = $name; + $this->arguments = $arguments; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * name: string, + * arguments?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['name']), + $_meta, + self::asStringMapOrNull($data['arguments'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['name'] = $this->name; + if ($this->arguments !== null) { + $result['arguments'] = $this->arguments; + } + + return $result; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return array|null + */ + public function getArguments(): ?array + { + return $this->arguments; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptResult.php new file mode 100644 index 0000000000..bc675f9892 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/GetPromptResult.php @@ -0,0 +1,128 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'description', 'messages']; + + /** + * An optional description for the prompt. + * + * @since 2024-11-05 + * + * @var string|null + */ + protected ?string $description; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Server\Prompts\DTO\PromptMessage> + */ + protected array $messages; + + /** + * @param array<\WP\McpSchema\Server\Prompts\DTO\PromptMessage> $messages @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param string|null $description @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $messages, + ?array $_meta = null, + ?string $description = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->messages = $messages; + $this->description = $description; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * description?: string|null, + * messages: array|\WP\McpSchema\Server\Prompts\DTO\PromptMessage> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['messages']); + + /** @var array<\WP\McpSchema\Server\Prompts\DTO\PromptMessage> $messages */ + $messages = array_map( + static fn($item) => is_array($item) + ? PromptMessage::fromArray($item) + : $item, + self::asArray($data['messages']) + ); + + return new self( + $messages, + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->description !== null) { + $result['description'] = $this->description; + } + $result['messages'] = array_map(static fn($item) => $item->toArray(), $this->messages); + + return $result; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array<\WP\McpSchema\Server\Prompts\DTO\PromptMessage> + */ + public function getMessages(): array + { + return $this->messages; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsRequest.php new file mode 100644 index 0000000000..61e450bc24 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsRequest.php @@ -0,0 +1,95 @@ +|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null, + * jsonrpc: '2.0', + * id: string|number, + * method: 'prompts/list' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsResult.php new file mode 100644 index 0000000000..0a715e9955 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/ListPromptsResult.php @@ -0,0 +1,107 @@ + + */ + private const KNOWN_KEYS = ['nextCursor', '_meta', 'prompts']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Server\Prompts\DTO\Prompt> + */ + protected array $prompts; + + /** + * @param array<\WP\McpSchema\Server\Prompts\DTO\Prompt> $prompts @since 2024-11-05 + * @param string|null $nextCursor @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $prompts, + ?string $nextCursor = null, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $nextCursor, $additionalProperties); + $this->prompts = $prompts; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * nextCursor?: string|null, + * _meta?: array|null, + * prompts: array|\WP\McpSchema\Server\Prompts\DTO\Prompt> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['prompts']); + + /** @var array<\WP\McpSchema\Server\Prompts\DTO\Prompt> $prompts */ + $prompts = array_map( + static fn($item) => is_array($item) + ? Prompt::fromArray($item) + : $item, + self::asArray($data['prompts']) + ); + + return new self( + $prompts, + self::asStringOrNull($data['nextCursor'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['prompts'] = array_map(static fn($item) => $item->toArray(), $this->prompts); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Server\Prompts\DTO\Prompt> + */ + public function getPrompts(): array + { + return $this->prompts; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/Prompt.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/Prompt.php new file mode 100644 index 0000000000..d863327792 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/Prompt.php @@ -0,0 +1,196 @@ +|null + */ + protected ?array $arguments; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + * + * @since 2025-06-18 + * + * @var array|null + */ + protected ?array $_meta; + + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + * + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + protected ?array $icons; + + /** + * @param string $name @since 2024-11-05 + * @param string|null $title @since 2025-06-18 + * @param string|null $description @since 2024-11-05 + * @param array<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|null $arguments @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $name, + ?string $title = null, + ?string $description = null, + ?array $arguments = null, + ?array $_meta = null, + ?array $icons = null + ) { + parent::__construct($name, $title); + $this->description = $description; + $this->arguments = $arguments; + $this->_meta = $_meta; + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * description?: string|null, + * arguments?: array|\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|null, + * _meta?: array|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + /** @var array<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|null $arguments */ + $arguments = isset($data['arguments']) + ? array_map( + static fn($item) => is_array($item) + ? PromptArgument::fromArray($item) + : $item, + self::asArray($data['arguments']) + ) + : null; + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['name']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + $arguments, + self::asArrayOrNull($data['_meta'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->arguments !== null) { + $result['arguments'] = array_map(static fn($item) => $item->toArray(), $this->arguments); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|null + */ + public function getArguments(): ?array + { + return $this->arguments; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptArgument.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptArgument.php new file mode 100644 index 0000000000..0389c80654 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptArgument.php @@ -0,0 +1,117 @@ +description = $description; + $this->required = $required; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * description?: string|null, + * required?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + return new self( + self::asString($data['name']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asBoolOrNull($data['required'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->required !== null) { + $result['required'] = $this->required; + } + + return $result; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return bool|null + */ + public function getRequired(): ?bool + { + return $this->required; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptListChangedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptListChangedNotification.php new file mode 100644 index 0000000000..7f80c9eeca --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptListChangedNotification.php @@ -0,0 +1,102 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/prompts/list_changed', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? NotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + */ + public function getTypedParams(): ?NotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptMessage.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptMessage.php new file mode 100644 index 0000000000..3cb9145bee --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Prompts/DTO/PromptMessage.php @@ -0,0 +1,113 @@ +role = $role; + $this->content = $content; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * role: 'user'|'assistant', + * content: array|\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['role', 'content']); + + /** @var 'user'|'assistant' $role */ + $role = self::asString($data['role']); + + /** @var \WP\McpSchema\Common\Protocol\Union\ContentBlockInterface $content */ + $content = is_array($data['content']) + ? ContentBlockFactory::fromArray(self::asArray($data['content'])) + : $data['content']; + + return new self( + $role, + $content + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['role'] = $this->role; + $result['content'] = $this->content->toArray(); + + return $result; + } + + /** + * @return 'user'|'assistant' + */ + public function getRole(): string + { + return $this->role; + } + + /** + * @return \WP\McpSchema\Common\Protocol\Union\ContentBlockInterface + */ + public function getContent(): ContentBlockInterface + { + return $this->content; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesRequest.php new file mode 100644 index 0000000000..9d4ce81ec1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesRequest.php @@ -0,0 +1,95 @@ +|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null, + * jsonrpc: '2.0', + * id: string|number, + * method: 'resources/templates/list' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesResult.php new file mode 100644 index 0000000000..29f0014d25 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourceTemplatesResult.php @@ -0,0 +1,107 @@ + + */ + private const KNOWN_KEYS = ['nextCursor', '_meta', 'resourceTemplates']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Server\Resources\DTO\ResourceTemplate> + */ + protected array $resourceTemplates; + + /** + * @param array<\WP\McpSchema\Server\Resources\DTO\ResourceTemplate> $resourceTemplates @since 2024-11-05 + * @param string|null $nextCursor @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $resourceTemplates, + ?string $nextCursor = null, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $nextCursor, $additionalProperties); + $this->resourceTemplates = $resourceTemplates; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * nextCursor?: string|null, + * _meta?: array|null, + * resourceTemplates: array|\WP\McpSchema\Server\Resources\DTO\ResourceTemplate> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['resourceTemplates']); + + /** @var array<\WP\McpSchema\Server\Resources\DTO\ResourceTemplate> $resourceTemplates */ + $resourceTemplates = array_map( + static fn($item) => is_array($item) + ? ResourceTemplate::fromArray($item) + : $item, + self::asArray($data['resourceTemplates']) + ); + + return new self( + $resourceTemplates, + self::asStringOrNull($data['nextCursor'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['resourceTemplates'] = array_map(static fn($item) => $item->toArray(), $this->resourceTemplates); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Server\Resources\DTO\ResourceTemplate> + */ + public function getResourceTemplates(): array + { + return $this->resourceTemplates; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesRequest.php new file mode 100644 index 0000000000..43641d5bcd --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesRequest.php @@ -0,0 +1,95 @@ +|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null, + * jsonrpc: '2.0', + * id: string|number, + * method: 'resources/list' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesResult.php new file mode 100644 index 0000000000..b0dcdc83e3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ListResourcesResult.php @@ -0,0 +1,107 @@ + + */ + private const KNOWN_KEYS = ['nextCursor', '_meta', 'resources']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Server\Resources\DTO\Resource> + */ + protected array $resources; + + /** + * @param array<\WP\McpSchema\Server\Resources\DTO\Resource> $resources @since 2024-11-05 + * @param string|null $nextCursor @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $resources, + ?string $nextCursor = null, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $nextCursor, $additionalProperties); + $this->resources = $resources; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * nextCursor?: string|null, + * _meta?: array|null, + * resources: array|\WP\McpSchema\Server\Resources\DTO\Resource> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['resources']); + + /** @var array<\WP\McpSchema\Server\Resources\DTO\Resource> $resources */ + $resources = array_map( + static fn($item) => is_array($item) + ? Resource::fromArray($item) + : $item, + self::asArray($data['resources']) + ); + + return new self( + $resources, + self::asStringOrNull($data['nextCursor'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['resources'] = array_map(static fn($item) => $item->toArray(), $this->resources); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Server\Resources\DTO\Resource> + */ + public function getResources(): array + { + return $this->resources; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequest.php new file mode 100644 index 0000000000..c7812bcc2e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'resources/read', + * params: array|\WP\McpSchema\Server\Resources\DTO\ReadResourceRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Resources\DTO\ReadResourceRequestParams $params */ + $params = is_array($data['params']) + ? ReadResourceRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Resources\DTO\ReadResourceRequestParams + */ + public function getTypedParams(): ReadResourceRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequestParams.php new file mode 100644 index 0000000000..f9b839d377 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceRequestParams.php @@ -0,0 +1,76 @@ +|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['uri']), + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceResult.php new file mode 100644 index 0000000000..24217a92f1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ReadResourceResult.php @@ -0,0 +1,98 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'contents']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> + */ + protected array $contents; + + /** + * @param array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> $contents @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $contents, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->contents = $contents; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * contents: array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['contents']); + + /** @var array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> $contents */ + $contents = self::asArray($data['contents']); + + return new self( + $contents, + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['contents'] = array_map(static fn($item) => (is_object($item) && method_exists($item, 'toArray')) ? $item->toArray() : $item, $this->contents); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Common\Protocol\DTO\TextResourceContents|\WP\McpSchema\Common\Protocol\DTO\BlobResourceContents> + */ + public function getContents(): array + { + return $this->contents; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/Resource.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/Resource.php new file mode 100644 index 0000000000..cae47503d1 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/Resource.php @@ -0,0 +1,271 @@ +|null + */ + protected ?array $_meta; + + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + * + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + protected ?array $icons; + + /** + * @param string $name @since 2024-11-05 + * @param string $uri @since 2024-11-05 + * @param string|null $title @since 2025-06-18 + * @param string|null $description @since 2024-11-05 + * @param string|null $mimeType @since 2024-11-05 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2024-11-05 + * @param int|null $size @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $name, + string $uri, + ?string $title = null, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ?int $size = null, + ?array $_meta = null, + ?array $icons = null + ) { + parent::__construct($name, $title); + $this->uri = $uri; + $this->description = $description; + $this->mimeType = $mimeType; + $this->annotations = $annotations; + $this->size = $size; + $this->_meta = $_meta; + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * uri: string, + * description?: string|null, + * mimeType?: string|null, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * size?: int|null, + * _meta?: array|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name', 'uri']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['name']), + self::asString($data['uri']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['mimeType'] ?? null), + $annotations, + self::asIntOrNull($data['size'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['uri'] = $this->uri; + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->mimeType !== null) { + $result['mimeType'] = $this->mimeType; + } + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->size !== null) { + $result['size'] = $this->size; + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return string|null + */ + public function getMimeType(): ?string + { + return $this->mimeType; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceContents.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceContents.php new file mode 100644 index 0000000000..d3eb37ed96 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceContents.php @@ -0,0 +1,131 @@ +|null + */ + protected ?array $_meta; + + /** + * @param string $uri @since 2024-11-05 + * @param string|null $mimeType @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + */ + public function __construct( + string $uri, + ?string $mimeType = null, + ?array $_meta = null + ) { + $this->uri = $uri; + $this->mimeType = $mimeType; + $this->_meta = $_meta; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * uri: string, + * mimeType?: string|null, + * _meta?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + return new self( + self::asString($data['uri']), + self::asStringOrNull($data['mimeType'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + $result['uri'] = $this->uri; + if ($this->mimeType !== null) { + $result['mimeType'] = $this->mimeType; + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + + return $result; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } + + /** + * @return string|null + */ + public function getMimeType(): ?string + { + return $this->mimeType; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceLink.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceLink.php new file mode 100644 index 0000000000..40f7015a36 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceLink.php @@ -0,0 +1,139 @@ +|null $_meta @since 2025-06-18 + * @param string|null $title @since 2025-06-18 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $uri, + string $name, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ?int $size = null, + ?array $_meta = null, + ?string $title = null, + ?array $icons = null + ) { + parent::__construct($name, $uri, $title, $description, $mimeType, $annotations, $size, $_meta, $icons); + $this->type = self::TYPE; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * uri: string, + * description?: string|null, + * mimeType?: string|null, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * size?: int|null, + * _meta?: array|null, + * name: string, + * title?: string|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null, + * type: 'resource_link' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri', 'name']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['uri']), + self::asString($data['name']), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['mimeType'] ?? null), + $annotations, + self::asIntOrNull($data['size'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::asStringOrNull($data['title'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['type'] = $this->type; + + return $result; + } + + /** + * @return 'resource_link' + */ + public function getType(): string + { + return $this->type; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceListChangedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceListChangedNotification.php new file mode 100644 index 0000000000..a289ef8598 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceListChangedNotification.php @@ -0,0 +1,102 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/resources/list_changed', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? NotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + */ + public function getTypedParams(): ?NotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceRequestParams.php new file mode 100644 index 0000000000..d9d8457559 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceRequestParams.php @@ -0,0 +1,93 @@ +uri = $uri; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * uri: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['uri']), + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['uri'] = $this->uri; + + return $result; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceTemplate.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceTemplate.php new file mode 100644 index 0000000000..040273b3e3 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceTemplate.php @@ -0,0 +1,244 @@ +|null + */ + protected ?array $_meta; + + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + * + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + protected ?array $icons; + + /** + * @param string $name @since 2024-11-05 + * @param string $uriTemplate @since 2024-11-05 + * @param string|null $title @since 2025-06-18 + * @param string|null $description @since 2024-11-05 + * @param string|null $mimeType @since 2024-11-05 + * @param \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations @since 2024-11-05 + * @param array|null $_meta @since 2025-06-18 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $name, + string $uriTemplate, + ?string $title = null, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ?array $_meta = null, + ?array $icons = null + ) { + parent::__construct($name, $title); + $this->uriTemplate = $uriTemplate; + $this->description = $description; + $this->mimeType = $mimeType; + $this->annotations = $annotations; + $this->_meta = $_meta; + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * uriTemplate: string, + * description?: string|null, + * mimeType?: string|null, + * annotations?: array|\WP\McpSchema\Common\Protocol\DTO\Annotations|null, + * _meta?: array|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name', 'uriTemplate']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\Annotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? Annotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['name']), + self::asString($data['uriTemplate']), + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + self::asStringOrNull($data['mimeType'] ?? null), + $annotations, + self::asArrayOrNull($data['_meta'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['uriTemplate'] = $this->uriTemplate; + if ($this->description !== null) { + $result['description'] = $this->description; + } + if ($this->mimeType !== null) { + $result['mimeType'] = $this->mimeType; + } + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return string + */ + public function getUriTemplate(): string + { + return $this->uriTemplate; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return string|null + */ + public function getMimeType(): ?string + { + return $this->mimeType; + } + + /** + * @return \WP\McpSchema\Common\Protocol\DTO\Annotations|null + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotification.php new file mode 100644 index 0000000000..0d693425fb --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotification.php @@ -0,0 +1,97 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/resources/updated', + * params: array|\WP\McpSchema\Server\Resources\DTO\ResourceUpdatedNotificationParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Server\Resources\DTO\ResourceUpdatedNotificationParams $params */ + $params = is_array($data['params']) + ? ResourceUpdatedNotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Resources\DTO\ResourceUpdatedNotificationParams + */ + public function getTypedParams(): ResourceUpdatedNotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotificationParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotificationParams.php new file mode 100644 index 0000000000..368304471f --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/ResourceUpdatedNotificationParams.php @@ -0,0 +1,85 @@ +|null $_meta @since 2025-11-25 + */ + public function __construct( + string $uri, + ?array $_meta = null + ) { + parent::__construct($_meta); + $this->uri = $uri; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * uri: string + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + return new self( + self::asString($data['uri']), + self::asArrayOrNull($data['_meta'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['uri'] = $this->uri; + + return $result; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequest.php new file mode 100644 index 0000000000..d81594cb9e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'resources/subscribe', + * params: array|\WP\McpSchema\Server\Resources\DTO\SubscribeRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Resources\DTO\SubscribeRequestParams $params */ + $params = is_array($data['params']) + ? SubscribeRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Resources\DTO\SubscribeRequestParams + */ + public function getTypedParams(): SubscribeRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequestParams.php new file mode 100644 index 0000000000..1caa3e0741 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/SubscribeRequestParams.php @@ -0,0 +1,76 @@ +|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['uri']), + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequest.php new file mode 100644 index 0000000000..5d7df23c42 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'resources/unsubscribe', + * params: array|\WP\McpSchema\Server\Resources\DTO\UnsubscribeRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Resources\DTO\UnsubscribeRequestParams $params */ + $params = is_array($data['params']) + ? UnsubscribeRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Resources\DTO\UnsubscribeRequestParams + */ + public function getTypedParams(): UnsubscribeRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequestParams.php new file mode 100644 index 0000000000..627ff42f0b --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Resources/DTO/UnsubscribeRequestParams.php @@ -0,0 +1,76 @@ +|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['uri']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['uri']), + $_meta + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequest.php new file mode 100644 index 0000000000..7b3a2ab82d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequest.php @@ -0,0 +1,104 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * id: string|number, + * method: 'tools/call', + * params: array|\WP\McpSchema\Server\Tools\DTO\CallToolRequestParams + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id', 'params']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Server\Tools\DTO\CallToolRequestParams $params */ + $params = is_array($data['params']) + ? CallToolRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['params'] = $this->typedParams->toArray(); + + return $result; + } + + /** + * @return \WP\McpSchema\Server\Tools\DTO\CallToolRequestParams + */ + public function getTypedParams(): CallToolRequestParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequestParams.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequestParams.php new file mode 100644 index 0000000000..5704c5089c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolRequestParams.php @@ -0,0 +1,130 @@ +|null + */ + protected ?array $arguments; + + /** + * @param string $name @since 2025-11-25 + * @param \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task @since 2025-11-25 + * @param \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta @since 2025-11-25 + * @param array|null $arguments @since 2025-11-25 + */ + public function __construct( + string $name, + ?TaskMetadata $task = null, + ?RequestParamsMeta $_meta = null, + ?array $arguments = null + ) { + parent::__construct($_meta, $task); + $this->name = $name; + $this->arguments = $arguments; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * task?: array|\WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null, + * _meta?: array|\WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null, + * name: string, + * arguments?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name']); + + /** @var \WP\McpSchema\Client\Tasks\DTO\TaskMetadata|null $task */ + $task = isset($data['task']) + ? (is_array($data['task']) + ? TaskMetadata::fromArray(self::asArray($data['task'])) + : $data['task']) + : null; + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\RequestParamsMeta|null $_meta */ + $_meta = isset($data['_meta']) + ? (is_array($data['_meta']) + ? RequestParamsMeta::fromArray(self::asArray($data['_meta'])) + : $data['_meta']) + : null; + + return new self( + self::asString($data['name']), + $task, + $_meta, + self::asArrayOrNull($data['arguments'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['name'] = $this->name; + if ($this->arguments !== null) { + $result['arguments'] = $this->arguments; + } + + return $result; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return array|null + */ + public function getArguments(): ?array + { + return $this->arguments; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolResult.php new file mode 100644 index 0000000000..4188344093 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/CallToolResult.php @@ -0,0 +1,168 @@ + + */ + private const KNOWN_KEYS = ['_meta', 'content', 'structuredContent', 'isError']; + + /** + * A list of content objects that represent the unstructured result of the tool call. + * + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> + */ + protected array $content; + + /** + * An optional JSON object that represents the structured result of the tool call. + * + * @since 2025-06-18 + * + * @var array|null + */ + protected ?array $structuredContent; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + * + * @since 2024-11-05 + * + * @var bool|null + */ + protected ?bool $isError; + + /** + * @param array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> $content @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $structuredContent @since 2025-06-18 + * @param bool|null $isError @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $content, + ?array $_meta = null, + ?array $structuredContent = null, + ?bool $isError = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $additionalProperties); + $this->content = $content; + $this->structuredContent = $structuredContent; + $this->isError = $isError; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * _meta?: array|null, + * content: array|\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface>, + * structuredContent?: array|null, + * isError?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['content']); + + /** @var array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> $content */ + $content = array_map( + static fn($item) => is_array($item) + ? ContentBlockFactory::fromArray($item) + : $item, + self::asArray($data['content']) + ); + + return new self( + $content, + self::asArrayOrNull($data['_meta'] ?? null), + self::asArrayOrNull($data['structuredContent'] ?? null), + self::asBoolOrNull($data['isError'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['content'] = array_map(static fn($item) => $item->toArray(), $this->content); + if ($this->structuredContent !== null) { + $result['structuredContent'] = $this->structuredContent; + } + if ($this->isError !== null) { + $result['isError'] = $this->isError; + } + + return $result; + } + + /** + * @return array<\WP\McpSchema\Common\Protocol\Union\ContentBlockInterface> + */ + public function getContent(): array + { + return $this->content; + } + + /** + * @return array|null + */ + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } + + /** + * @return bool|null + */ + public function getIsError(): ?bool + { + return $this->isError; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsRequest.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsRequest.php new file mode 100644 index 0000000000..aa55b1d0af --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsRequest.php @@ -0,0 +1,95 @@ +|\WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null, + * jsonrpc: '2.0', + * id: string|number, + * method: 'tools/list' + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc', 'id']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var string|number $id */ + $id = self::asStringOrNumber($data['id']); + + /** @var \WP\McpSchema\Common\Protocol\DTO\PaginatedRequestParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? PaginatedRequestParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $id, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + return $result; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsResult.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsResult.php new file mode 100644 index 0000000000..410b4c8638 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ListToolsResult.php @@ -0,0 +1,107 @@ + + */ + private const KNOWN_KEYS = ['nextCursor', '_meta', 'tools']; + + /** + * @since 2024-11-05 + * + * @var array<\WP\McpSchema\Server\Tools\DTO\Tool> + */ + protected array $tools; + + /** + * @param array<\WP\McpSchema\Server\Tools\DTO\Tool> $tools @since 2024-11-05 + * @param string|null $nextCursor @since 2024-11-05 + * @param array|null $_meta @since 2024-11-05 + * @param array|null $additionalProperties + */ + public function __construct( + array $tools, + ?string $nextCursor = null, + ?array $_meta = null, + ?array $additionalProperties = null + ) { + parent::__construct($_meta, $nextCursor, $additionalProperties); + $this->tools = $tools; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * nextCursor?: string|null, + * _meta?: array|null, + * tools: array|\WP\McpSchema\Server\Tools\DTO\Tool> + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['tools']); + + /** @var array<\WP\McpSchema\Server\Tools\DTO\Tool> $tools */ + $tools = array_map( + static fn($item) => is_array($item) + ? Tool::fromArray($item) + : $item, + self::asArray($data['tools']) + ); + + return new self( + $tools, + self::asStringOrNull($data['nextCursor'] ?? null), + self::asArrayOrNull($data['_meta'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + $result['tools'] = array_map(static fn($item) => $item->toArray(), $this->tools); + + return $result; + } + + /** + * @return array<\WP\McpSchema\Server\Tools\DTO\Tool> + */ + public function getTools(): array + { + return $this->tools; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/Tool.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/Tool.php new file mode 100644 index 0000000000..6b107d2489 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/Tool.php @@ -0,0 +1,293 @@ +|null + */ + protected ?array $_meta; + + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + * + * @since 2025-11-25 + * + * @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + protected ?array $icons; + + /** + * @param string $name @since 2024-11-05 + * @param \WP\McpSchema\Server\Tools\DTO\ToolInputSchema $inputSchema @since 2024-11-05 + * @param string|null $title @since 2025-06-18 + * @param string|null $description @since 2024-11-05 + * @param \WP\McpSchema\Server\Tools\DTO\ToolExecution|null $execution @since 2025-11-25 + * @param \WP\McpSchema\Server\Tools\DTO\ToolOutputSchema|null $outputSchema @since 2025-06-18 + * @param \WP\McpSchema\Server\Tools\DTO\ToolAnnotations|null $annotations @since 2025-03-26 + * @param array|null $_meta @since 2025-06-18 + * @param array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons @since 2025-11-25 + */ + public function __construct( + string $name, + ToolInputSchema $inputSchema, + ?string $title = null, + ?string $description = null, + ?ToolExecution $execution = null, + ?ToolOutputSchema $outputSchema = null, + ?ToolAnnotations $annotations = null, + ?array $_meta = null, + ?array $icons = null + ) { + parent::__construct($name, $title); + $this->inputSchema = $inputSchema; + $this->description = $description; + $this->execution = $execution; + $this->outputSchema = $outputSchema; + $this->annotations = $annotations; + $this->_meta = $_meta; + $this->icons = $icons; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * name: string, + * title?: string|null, + * description?: string|null, + * inputSchema: array|\WP\McpSchema\Server\Tools\DTO\ToolInputSchema, + * execution?: array|\WP\McpSchema\Server\Tools\DTO\ToolExecution|null, + * outputSchema?: array|\WP\McpSchema\Server\Tools\DTO\ToolOutputSchema|null, + * annotations?: array|\WP\McpSchema\Server\Tools\DTO\ToolAnnotations|null, + * _meta?: array|null, + * icons?: array|\WP\McpSchema\Common\Core\DTO\Icon>|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['name', 'inputSchema']); + + /** @var \WP\McpSchema\Server\Tools\DTO\ToolInputSchema $inputSchema */ + $inputSchema = is_array($data['inputSchema']) + ? ToolInputSchema::fromArray(self::asArray($data['inputSchema'])) + : $data['inputSchema']; + + /** @var \WP\McpSchema\Server\Tools\DTO\ToolExecution|null $execution */ + $execution = isset($data['execution']) + ? (is_array($data['execution']) + ? ToolExecution::fromArray(self::asArray($data['execution'])) + : $data['execution']) + : null; + + /** @var \WP\McpSchema\Server\Tools\DTO\ToolOutputSchema|null $outputSchema */ + $outputSchema = isset($data['outputSchema']) + ? (is_array($data['outputSchema']) + ? ToolOutputSchema::fromArray(self::asArray($data['outputSchema'])) + : $data['outputSchema']) + : null; + + /** @var \WP\McpSchema\Server\Tools\DTO\ToolAnnotations|null $annotations */ + $annotations = isset($data['annotations']) + ? (is_array($data['annotations']) + ? ToolAnnotations::fromArray(self::asArray($data['annotations'])) + : $data['annotations']) + : null; + + /** @var array<\WP\McpSchema\Common\Core\DTO\Icon>|null $icons */ + $icons = isset($data['icons']) + ? array_map( + static fn($item) => is_array($item) + ? Icon::fromArray($item) + : $item, + self::asArray($data['icons']) + ) + : null; + + return new self( + self::asString($data['name']), + $inputSchema, + self::asStringOrNull($data['title'] ?? null), + self::asStringOrNull($data['description'] ?? null), + $execution, + $outputSchema, + $annotations, + self::asArrayOrNull($data['_meta'] ?? null), + $icons + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->description !== null) { + $result['description'] = $this->description; + } + $result['inputSchema'] = $this->inputSchema->toArray(); + if ($this->execution !== null) { + $result['execution'] = $this->execution->toArray(); + } + if ($this->outputSchema !== null) { + $result['outputSchema'] = $this->outputSchema->toArray(); + } + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + if ($this->_meta !== null) { + $result['_meta'] = $this->_meta; + } + if ($this->icons !== null) { + $result['icons'] = array_map(static fn($item) => $item->toArray(), $this->icons); + } + + return $result; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return \WP\McpSchema\Server\Tools\DTO\ToolInputSchema + */ + public function getInputSchema(): ToolInputSchema + { + return $this->inputSchema; + } + + /** + * @return \WP\McpSchema\Server\Tools\DTO\ToolExecution|null + */ + public function getExecution(): ?ToolExecution + { + return $this->execution; + } + + /** + * @return \WP\McpSchema\Server\Tools\DTO\ToolOutputSchema|null + */ + public function getOutputSchema(): ?ToolOutputSchema + { + return $this->outputSchema; + } + + /** + * @return \WP\McpSchema\Server\Tools\DTO\ToolAnnotations|null + */ + public function getAnnotations(): ?ToolAnnotations + { + return $this->annotations; + } + + /** + * @return array|null + */ + public function get_meta(): ?array + { + return $this->_meta; + } + + /** + * @return array<\WP\McpSchema\Common\Core\DTO\Icon>|null + */ + public function getIcons(): ?array + { + return $this->icons; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolAnnotations.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolAnnotations.php new file mode 100644 index 0000000000..86b741f863 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolAnnotations.php @@ -0,0 +1,204 @@ +title = $title; + $this->readOnlyHint = $readOnlyHint; + $this->destructiveHint = $destructiveHint; + $this->idempotentHint = $idempotentHint; + $this->openWorldHint = $openWorldHint; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * title?: string|null, + * readOnlyHint?: bool|null, + * destructiveHint?: bool|null, + * idempotentHint?: bool|null, + * openWorldHint?: bool|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['title'] ?? null), + self::asBoolOrNull($data['readOnlyHint'] ?? null), + self::asBoolOrNull($data['destructiveHint'] ?? null), + self::asBoolOrNull($data['idempotentHint'] ?? null), + self::asBoolOrNull($data['openWorldHint'] ?? null) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->title !== null) { + $result['title'] = $this->title; + } + if ($this->readOnlyHint !== null) { + $result['readOnlyHint'] = $this->readOnlyHint; + } + if ($this->destructiveHint !== null) { + $result['destructiveHint'] = $this->destructiveHint; + } + if ($this->idempotentHint !== null) { + $result['idempotentHint'] = $this->idempotentHint; + } + if ($this->openWorldHint !== null) { + $result['openWorldHint'] = $this->openWorldHint; + } + + return $result; + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @return bool|null + */ + public function getReadOnlyHint(): ?bool + { + return $this->readOnlyHint; + } + + /** + * @return bool|null + */ + public function getDestructiveHint(): ?bool + { + return $this->destructiveHint; + } + + /** + * @return bool|null + */ + public function getIdempotentHint(): ?bool + { + return $this->idempotentHint; + } + + /** + * @return bool|null + */ + public function getOpenWorldHint(): ?bool + { + return $this->openWorldHint; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolExecution.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolExecution.php new file mode 100644 index 0000000000..1de7eba173 --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolExecution.php @@ -0,0 +1,93 @@ +taskSupport = $taskSupport; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * taskSupport?: 'forbidden'|'optional'|'required'|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var 'forbidden'|'optional'|'required'|null $taskSupport */ + $taskSupport = isset($data['taskSupport']) + ? self::asStringOrNull($data['taskSupport']) + : null; + + return new self( + $taskSupport + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->taskSupport !== null) { + $result['taskSupport'] = $this->taskSupport; + } + + return $result; + } + + /** + * @return 'forbidden'|'optional'|'required'|null + */ + public function getTaskSupport(): ?string + { + return $this->taskSupport; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolInputSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolInputSchema.php new file mode 100644 index 0000000000..0877e07c8c --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolInputSchema.php @@ -0,0 +1,160 @@ + + */ + private const KNOWN_KEYS = ['$schema', 'type', 'properties', 'required']; + + /** + * @var string|null + */ + protected ?string $schema; + + /** + * @var 'object' + */ + protected string $type; + + /** + * @var array|null + */ + protected ?array $properties; + + /** + * @var array|null + */ + protected ?array $required; + + /** + * Keys carried on the wire that this type does not model. Preserved verbatim so unrecognized fields survive a round trip. + * + * @var array|null + */ + protected ?array $additionalProperties; + + /** + * @param string|null $schema + * @param array|null $properties + * @param array|null $required + * @param array|null $additionalProperties + */ + public function __construct( + ?string $schema = null, + ?array $properties = null, + ?array $required = null, + ?array $additionalProperties = null + ) { + $this->type = self::TYPE; + $this->schema = $schema; + $this->properties = $properties; + $this->required = $required; + $this->additionalProperties = $additionalProperties; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * '$schema'?: string|null, + * type: 'object', + * properties?: array|null, + * required?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['$schema'] ?? null), + self::asObjectMapOrNull($data['properties'] ?? null), + self::asStringArrayOrNull($data['required'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->schema !== null) { + $result['$schema'] = $this->schema; + } + $result['type'] = $this->type; + $result['properties'] = !empty($this->properties) + ? $this->properties + : new \stdClass(); + if ($this->required !== null) { + $result['required'] = $this->required; + } + + return $result + ($this->additionalProperties ?? []); + } + + /** + * @return string|null + */ + public function getSchema(): ?string + { + return $this->schema; + } + + /** + * @return 'object' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return array|null + */ + public function getProperties(): ?array + { + return $this->properties; + } + + /** + * @return array|null + */ + public function getRequired(): ?array + { + return $this->required; + } + + /** + * @return array|null + */ + public function getAdditionalProperties(): ?array + { + return $this->additionalProperties; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolListChangedNotification.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolListChangedNotification.php new file mode 100644 index 0000000000..401f66e35e --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolListChangedNotification.php @@ -0,0 +1,102 @@ +typedParams = $params; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * jsonrpc: '2.0', + * method: 'notifications/tools/list_changed', + * params?: array|\WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + self::assertRequired($data, ['jsonrpc']); + + /** @var '2.0' $jsonrpc */ + $jsonrpc = self::asString($data['jsonrpc']); + + /** @var \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null $params */ + $params = isset($data['params']) + ? (is_array($data['params']) + ? NotificationParams::fromArray(self::asArray($data['params'])) + : $data['params']) + : null; + + return new self( + $jsonrpc, + $params + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = parent::toArray(); + + if ($this->typedParams !== null) { + $result['params'] = $this->typedParams->toArray(); + } + + return $result; + } + + /** + * @return \WP\McpSchema\Common\JsonRpc\DTO\NotificationParams|null + */ + public function getTypedParams(): ?NotificationParams + { + return $this->typedParams; + } +} diff --git a/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolOutputSchema.php b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolOutputSchema.php new file mode 100644 index 0000000000..2d57b78a5d --- /dev/null +++ b/lib/vendor/wordpress/php-mcp-schema/src/Server/Tools/DTO/ToolOutputSchema.php @@ -0,0 +1,164 @@ + + */ + private const KNOWN_KEYS = ['$schema', 'type', 'properties', 'required']; + + /** + * @var string|null + */ + protected ?string $schema; + + /** + * @var 'object' + */ + protected string $type; + + /** + * @var array|null + */ + protected ?array $properties; + + /** + * @var array|null + */ + protected ?array $required; + + /** + * Keys carried on the wire that this type does not model. Preserved verbatim so unrecognized fields survive a round trip. + * + * @var array|null + */ + protected ?array $additionalProperties; + + /** + * @param string|null $schema + * @param array|null $properties + * @param array|null $required + * @param array|null $additionalProperties + */ + public function __construct( + ?string $schema = null, + ?array $properties = null, + ?array $required = null, + ?array $additionalProperties = null + ) { + $this->type = self::TYPE; + $this->schema = $schema; + $this->properties = $properties; + $this->required = $required; + $this->additionalProperties = $additionalProperties; + } + + /** + * Creates an instance from an array. + * + * @param array{ + * '$schema'?: string|null, + * type: 'object', + * properties?: array|null, + * required?: array|null + * } $data + * @phpstan-param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + self::asStringOrNull($data['$schema'] ?? null), + self::asObjectMapOrNull($data['properties'] ?? null), + self::asStringArrayOrNull($data['required'] ?? null), + self::additionalFields($data, self::KNOWN_KEYS) + ); + } + + /** + * Converts the instance to an array. + * + * @return array + */ + public function toArray(): array + { + $result = []; + + if ($this->schema !== null) { + $result['$schema'] = $this->schema; + } + $result['type'] = $this->type; + $result['properties'] = !empty($this->properties) + ? $this->properties + : new \stdClass(); + if ($this->required !== null) { + $result['required'] = $this->required; + } + + return $result + ($this->additionalProperties ?? []); + } + + /** + * @return string|null + */ + public function getSchema(): ?string + { + return $this->schema; + } + + /** + * @return 'object' + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return array|null + */ + public function getProperties(): ?array + { + return $this->properties; + } + + /** + * @return array|null + */ + public function getRequired(): ?array + { + return $this->required; + } + + /** + * @return array|null + */ + public function getAdditionalProperties(): ?array + { + return $this->additionalProperties; + } +} diff --git a/stubs.php b/stubs.php index 78d6178333..0b74a89e79 100644 --- a/stubs.php +++ b/stubs.php @@ -175,6 +175,11 @@ public static function admin_banner() { */ public static function get_readable_license_type() { } + /** + * @return string Either grace, expired, expiring, or active. + */ + public static function get_license_status() { + } } class FrmProCurrencyHelper { public static function normalize_formatted_numbers( $field, $formatted_value ) {} @@ -277,9 +282,35 @@ public static function mod_other_vals( $values = false, $location = 'front' ) { } class FrmProEntryFormatter extends FrmEntryFormatter { } + /** + * The Registration add-on's entry controller. Constructing it is what lets a + * registration form's entry run its user creation, and the abilities do that + * behind a class_exists() guard. + */ + class FrmRegEntryController { + } + class FrmProField { + /** + * @param int $field_id ID of the repeater field, or 0 while it is being created. + * @param array $args Accepts parent_form_id and field_name. + * + * @return int ID of the child form that holds the repeater's fields. + */ + public static function create_repeat_form( $field_id, $args = array() ) { + } + } class FrmProEntriesHelper { public static function get_search_str( $where_clause, $search_str, $form_id = 0, $fid = '' ) { } + /** + * @param string $search The search term. + * @param int|string $form_id The form to search in. + * @param array $args Extra query args, such as is_draft. + * + * @return array Entry IDs matching the search. + */ + public static function get_search_ids( $search, $form_id, $args = array() ) { + } /** * @param object $field * @param object $entry @@ -886,6 +917,140 @@ public function get_template_string() { */ function rand_str( $length = 32 ) { } + /** + * The WordPress Abilities API, added in WordPress 7.0 and so absent from the + * wordpress-stubs release this plugin pins. + */ + class WP_Ability { + /** + * @return string + */ + public function get_name() { + } + /** + * @return array + */ + public function get_input_schema() { + } + /** + * @param array $input Ability input parameters. + * + * @return mixed + */ + public function execute( $input = array() ) { + } + } + class WP_Ability_Category { + } + class WP_Abilities_Registry { + /** + * @return WP_Abilities_Registry|null + */ + public static function get_instance() { + } + /** + * @param string $name Ability name. + * + * @return bool + */ + public function is_registered( $name ) { + } + } + class WP_Ability_Categories_Registry { + /** + * @return WP_Ability_Categories_Registry|null + */ + public static function get_instance() { + } + /** + * @param string $slug Category slug. + * + * @return bool + */ + public function is_registered( $slug ) { + } + } + /** + * @param string $name Ability name, including its namespace prefix. + * @param array $args Ability definition. + * + * @return WP_Ability|null + */ + function wp_register_ability( $name, $args ) { + } + /** + * @param string $slug Category slug. + * @param array $args Category definition. + * + * @return WP_Ability_Category|null + */ + function wp_register_ability_category( $slug, $args ) { + } + /** + * @param string $name Ability name. + * + * @return bool + */ + function wp_has_ability( $name ) { + } + /** + * @param string $name Ability name. + * + * @return WP_Ability|null + */ + function wp_get_ability( $name ) { + } + /** + * @return array + */ + function wp_get_abilities() { + } + /** + * @param string $slug Category slug. + * + * @return WP_Ability_Category|null + */ + function wp_get_ability_category( $slug ) { + } + /** + * @param string $name Ability name. + * + * @return bool + */ + function wp_unregister_ability( $name ) { + } +} + +/** + * The MCP adapter vendored in lib/vendor. Only what FrmMcpController calls is + * stubbed: the adapter is loaded conditionally at runtime, so analysis cannot + * see it, and FrmMcpCompat checks for the real thing before any of this is used. + */ +namespace WP\MCP\Core { + class McpAdapter { + const VERSION = ''; + /** + * @return McpAdapter + */ + public static function instance() { + } + /** + * @param string $server_id Unique server ID. + * @param string $server_route_namespace REST namespace to serve on. + * @param string $server_route Route within the namespace. + * @param string $server_name Human readable server name. + * @param string $server_description Human readable server description. + * @param string $server_version Server version string. + * @param array $mcp_transports Transport class names. + * @param string|null $error_handler Error handler class name. + * @param string|null $observability_handler Observability handler class name. + * @param array $tools Ability names exposed as tools. + * + * @return mixed True on success, or WP_Error on failure. + */ + public function create_server( $server_id, $server_route_namespace, $server_route, $server_name, $server_description, $server_version, $mcp_transports, $error_handler, $observability_handler = null, $tools = array() ) { + } + } } namespace Elementor { diff --git a/tests/phpunit/abilities/test_FrmAbilitiesContract.php b/tests/phpunit/abilities/test_FrmAbilitiesContract.php new file mode 100644 index 0000000000..ec002ed51d --- /dev/null +++ b/tests/phpunit/abilities/test_FrmAbilitiesContract.php @@ -0,0 +1,441 @@ + + */ + private static $owned_domains = array( 'forms', 'fields', 'entries', 'styles', 'form-actions' ); + + /** + * Abilities that legitimately take no required input. + * + * @var array + */ + private static $no_required_input_abilities = array( + 'formidable-forms/list-forms', + 'formidable-forms/list-entries', + 'formidable-forms/list-styles', + ); + + /** + * @return void + */ + public function setUp(): void { + parent::setUp(); + + if ( ! function_exists( 'wp_get_abilities' ) ) { + $this->markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + } + + /** + * Turn the abilities surface on and register only Formidable's own + * abilities, so this file's assertions are not affected by whichever + * add-ons happen to be active in this process. + * + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @return array + */ + private function get_formidable_abilities() { + $abilities = array(); + + foreach ( wp_get_abilities() as $name => $ability ) { + if ( str_starts_with( $name, 'formidable-forms/' ) ) { + $abilities[ $name ] = $ability; + } + } + + return $abilities; + } + + /** + * @covers FrmAbilitiesController::register_abilities + * + * @return void + */ + public function test_abilities_are_registered() { + $abilities = $this->get_formidable_abilities(); + + $expected = array( + 'list-forms', + 'get-form', + 'create-form', + 'update-form', + 'delete-form', + 'list-fields', + 'create-field', + 'update-field', + 'delete-field', + 'list-entries', + 'get-entry', + 'delete-entry', + 'list-styles', + 'get-style', + 'update-style', + 'list-form-actions', + 'get-form-action', + 'create-form-action', + 'update-form-action', + 'delete-form-action', + ); + + foreach ( $expected as $slug ) { + $this->assertArrayHasKey( 'formidable-forms/' . $slug, $abilities, 'The ' . $slug . ' ability should be registered.' ); + } + } + + /** + * Every domain Formidable owns has to actually claim it through the + * shared filter, or an add-on with an older copy of a sibling plugin + * would keep registering it too. + * + * @covers FrmAbilitiesController::owns + * @covers FrmAbilitiesController::domains + * + * @return void + */ + public function test_owns_reflects_every_domain_formidable_registers() { + foreach ( self::$owned_domains as $domain ) { + $this->assertTrue( FrmAbilitiesController::owns( $domain ), 'Formidable should own the ' . $domain . ' domain.' ); + } + + $this->assertFalse( FrmAbilitiesController::owns( 'not-a-real-domain' ), 'A domain nothing maps to a class for should not be owned.' ); + } + + /** + * Owns() is what every deferring plugin calls before registering, so it + * has to answer false the moment the abilities surface itself is off, + * not just when a domain is unmapped. + * + * @covers FrmAbilitiesController::owns + * @covers FrmAbilitiesController::is_active + * + * @return void + */ + public function test_owns_is_false_for_every_domain_while_mcp_is_off() { + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 0; + $frm_settings->store(); + FrmMcpController::reset(); + + foreach ( self::$owned_domains as $domain ) { + $this->assertFalse( + FrmAbilitiesController::owns( $domain ), + 'owns() should be false for ' . $domain . ' while MCP is off, even though the domain filter still maps it.' + ); + } + + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + } + + /** + * The MCP setting is one switch for the whole AI surface, so nothing + * registers while it is off. + * + * @covers FrmAbilitiesController::register_abilities + * + * @return void + */ + public function test_nothing_registers_while_mcp_is_off() { + foreach ( array_keys( $this->get_formidable_abilities() ) as $name ) { + wp_unregister_ability( $name ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 0; + $frm_settings->store(); + FrmMcpController::reset(); + + FrmAbilitiesController::register_abilities(); + + $this->assertSame( array(), $this->get_formidable_abilities(), 'No ability should register while MCP is off.' ); + + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + $this->enable_abilities(); + } + + /** + * @return void + */ + public function test_every_ability_is_described() { + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $this->assertNotEmpty( $ability->get_label(), $name . ' should have a label.' ); + $this->assertNotEmpty( $ability->get_description(), $name . ' should have a description.' ); + } + } + + /** + * @return void + */ + public function test_every_input_property_is_described() { + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $schema = $ability->get_input_schema(); + + if ( in_array( $name, self::$no_required_input_abilities, true ) ) { + // These abilities' schemas still declare properties (page, + // page_size, order...), just none of them required. + continue; + } + + $this->assertNotEmpty( $schema['properties'] ?? array(), $name . ' should declare its input properties.' ); + } + + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $schema = $ability->get_input_schema(); + + foreach ( $schema['properties'] ?? array() as $property => $definition ) { + $this->assertNotEmpty( + $definition['description'] ?? '', + $name . ' is missing a description for the ' . $property . ' input.' + ); + } + } + } + + /** + * @return void + */ + public function test_no_ability_is_callable_when_logged_out() { + $abilities = $this->get_formidable_abilities(); + + wp_set_current_user( 0 ); + + foreach ( $abilities as $name => $ability ) { + $allowed = $ability->check_permissions( array() ); + + if ( is_wp_error( $allowed ) ) { + continue; + } + + $this->assertFalse( $allowed, $name . ' should not be callable by a logged out visitor.' ); + } + + $this->set_current_user_to_1(); + } + + /** + * @return void + */ + public function test_abilities_are_callable_by_an_administrator() { + $this->set_current_user_to_1(); + + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $allowed = $ability->check_permissions( array() ); + + $this->assertNotWPError( $allowed, $name . ' should not error when checking administrator permission.' ); + $this->assertTrue( $allowed, $name . ' should be callable by an administrator.' ); + } + } + + /** + * @return void + */ + public function test_annotations_match_what_the_ability_does() { + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $annotations = $ability->get_meta()['annotations'] ?? array(); + + $this->assertArrayHasKey( 'readonly', $annotations, $name . ' should annotate readonly.' ); + $this->assertArrayHasKey( 'destructive', $annotations, $name . ' should annotate destructive.' ); + $this->assertArrayHasKey( 'idempotent', $annotations, $name . ' should annotate idempotent.' ); + + $slug = str_replace( 'formidable-forms/', '', $name ); + $is_read = str_starts_with( $slug, 'get-' ) || str_starts_with( $slug, 'list-' ); + $is_write = str_starts_with( $slug, 'create-' ) || str_starts_with( $slug, 'update-' ) || str_starts_with( $slug, 'delete-' ); + + if ( $is_read ) { + $this->assertTrue( $annotations['readonly'], $name . ' reads data, so it should be readonly.' ); + $this->assertFalse( $annotations['destructive'], $name . ' reads data, so it should not be destructive.' ); + } + + if ( $is_write ) { + $this->assertFalse( $annotations['readonly'], $name . ' writes data, so it should not be readonly.' ); + } + + if ( str_starts_with( $slug, 'delete-' ) ) { + $this->assertTrue( $annotations['destructive'], $name . ' deletes data, so it should be destructive.' ); + } + } + } + + /** + * @return void + */ + public function test_every_ability_is_exposed_to_mcp_and_rest() { + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $meta = $ability->get_meta(); + + $this->assertNotEmpty( $meta['show_in_rest'], $name . ' should be exposed in wp-abilities/v1.' ); + $this->assertNotEmpty( $meta['mcp']['public'], $name . ' should be public to the MCP server.' ); + } + } + + /** + * @return void + */ + public function test_every_ability_is_in_the_formidable_category() { + foreach ( $this->get_formidable_abilities() as $name => $ability ) { + $this->assertSame( 'formidable-forms', $ability->get_category(), $name . ' should be in the formidable-forms category.' ); + } + } + + /** + * A missing resource is a 404 through every ability that takes an id, not + * a bare error, and the status has to survive whatever wraps the error on + * the way out. + * + * @return void + */ + public function test_get_abilities_report_not_found_for_a_missing_id() { + $missing_id = 99999999; + + $cases = array( + 'formidable-forms/get-form' => array( 'id' => $missing_id ), + 'formidable-forms/get-entry' => array( 'id' => $missing_id ), + 'formidable-forms/get-style' => array( 'id' => $missing_id ), + 'formidable-forms/get-form-action' => array( 'id' => $missing_id ), + ); + + $abilities = $this->get_formidable_abilities(); + + foreach ( $cases as $name => $input ) { + $this->assertArrayHasKey( $name, $abilities, $name . ' should be registered.' ); + + $result = $abilities[ $name ]->execute( $input ); + + $this->assertWPError( $result, $name . ' should error for an id that does not exist.' ); + $this->assertSame( + 404, + $result->get_error_data()['status'] ?? null, + $name . ' should report a 404 for an id that does not exist.' + ); + } + } + + /** + * This is the headline correctness case from Garret's report: list-forms + * failed with a generic error on empty parameters ({}) but succeeded the + * instant any key, even a made up one, was added. Vivi traced the root + * cause to the vendored mcp-adapter's AbilityArgumentNormalizer, which + * collapses both null and {} to [] for a schema with no top level + * default — a layer that sits in front of WP_Ability::execute() and is + * only reached through the real MCP JSON-RPC transport. + * + * This test calls the ability directly with an empty array, which is + * what a normalized {} becomes either way. It passes here because + * FrmAbilitiesFormsController::execute_list_forms() builds every default + * itself through FrmAbilitiesHelper::prepare_order_and_limit(), so it + * never depends on the schema's own per-property defaults having been + * applied. That means a green result here does NOT confirm Garret's bug + * is fixed — it confirms Formidable's own controller has no such bug of + * its own. The actual defect lives in the mcp-adapter package one layer + * further out, in front of every ability's execute(), and needs an + * integration-level test that goes through the real MCP dispatch path + * (or a unit test directly against AbilityArgumentNormalizer) to cover. + * + * @covers FrmAbilitiesFormsController::execute_list_forms + * + * @return void + */ + public function test_list_forms_succeeds_with_no_parameters() { + $ability = wp_get_ability( 'formidable-forms/list-forms' ); + $result = $ability->execute( array() ); + $this->assertNotWPError( $result, 'list-forms should succeed with an empty parameters array, not error the way {} does through the real MCP transport.' ); + $this->assertIsArray( $result ); + + $result_with_extra_key = $ability->execute( array( 'zzz' => 1 ) ); + $this->assertNotWPError( $result_with_extra_key, 'list-forms should also succeed with an irrelevant extra key, confirming this is the same ability Garret tested.' ); + $this->assertSame( $result, $result_with_extra_key, 'An unrecognized extra key should not change the result.' ); + } + + /** + * Same check for list-entries and list-styles, the other two Formidable + * owns among the abilities Garret's report named or resembles: neither + * has a required input, so both are exposed to the same normalizer path. + * Same caveat as above: green here is about Formidable's own controllers, + * not proof the mcp-adapter defect is fixed. + * + * @return void + */ + public function test_list_entries_and_list_styles_succeed_with_no_parameters() { + foreach ( array( 'formidable-forms/list-entries', 'formidable-forms/list-styles' ) as $name ) { + $result = wp_get_ability( $name )->execute( array() ); + $this->assertNotWPError( $result, $name . ' should succeed with an empty parameters array.' ); + $this->assertIsArray( $result ); + } + } + + /** + * Get-style and delete-style used to answer for any WordPress post, not + * just a Formidable style, because FrmStyle::get_one() reads the row with + * get_post(), which does not check post_type. FrmAbilitiesStylesController::get_style() + * now guards with is_style_post() before returning, so an unrelated post + * (a page, in this test) has to read back as a 404, not as fabricated + * style data. + * + * @covers FrmAbilitiesStylesController::get_style + * @covers FrmAbilitiesStylesController::is_style_post + * + * @return void + */ + public function test_get_style_refuses_a_post_that_is_not_a_style() { + $unrelated_post_id = self::factory()->post->create( + array( + 'post_title' => 'Not a style', + 'post_type' => 'page', + ) + ); + $ability = wp_get_ability( 'formidable-forms/get-style' ); + $result = $ability->execute( array( 'id' => $unrelated_post_id ) ); + + $this->assertWPError( $result, 'get-style should refuse a post ID that is not a Formidable style.' ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null, 'A non-style post should read back as a 404, not as style data.' ); + + wp_delete_post( $unrelated_post_id, true ); + } +} diff --git a/tests/phpunit/abilities/test_FrmAbilitiesFormActionsController.php b/tests/phpunit/abilities/test_FrmAbilitiesFormActionsController.php new file mode 100644 index 0000000000..7e1bcbaecf --- /dev/null +++ b/tests/phpunit/abilities/test_FrmAbilitiesFormActionsController.php @@ -0,0 +1,313 @@ +markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + $this->form = $this->factory->form->create_and_get(); + } + + /** + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @param string $slug Ability slug, without the formidable-forms prefix. + * @param array $input Ability input parameters. + * + * @return mixed + */ + private function execute( $slug, $input = array() ) { + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + + $this->assertInstanceOf( \WP_Ability::class, $ability, 'The ' . $slug . ' ability should be registered.' ); + + return $ability->execute( $input ); + } + + /** + * @return void + */ + public function test_a_form_action_round_trips_through_create_list_get_update_delete() { + $created = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + 'post_title' => 'Notify Admin', + ) + ); + + $this->assertNotWPError( $created, 'create-form-action should succeed with a form_id and a type.' ); + $this->assertSame( 'email', $created['type'] ); + $this->assertSame( 'Notify Admin', $created['post_title'] ); + $this->assertSame( (string) $this->form->id, (string) $created['form_id'] ); + + $listed = $this->execute( 'list-form-actions', array( 'form_id' => $this->form->id ) ); + $this->assertNotWPError( $listed ); + $this->assertArrayHasKey( $created['id'], $listed, 'The created action should be in the listing.' ); + + $fetched = $this->execute( 'get-form-action', array( 'id' => $created['id'] ) ); + $this->assertNotWPError( $fetched ); + $this->assertSame( $created['id'], $fetched['id'] ); + + $updated = $this->execute( + 'update-form-action', + array( + 'id' => $created['id'], + 'post_title' => 'Renamed Action', + ) + ); + $this->assertNotWPError( $updated ); + $this->assertSame( 'Renamed Action', $updated['post_title'] ); + $this->assertSame( 'email', $updated['type'], 'The type should survive an update that does not touch it.' ); + + $deleted = $this->execute( 'delete-form-action', array( 'id' => $created['id'] ) ); + $this->assertNotWPError( $deleted ); + $this->assertSame( $created['id'], $deleted['id'] ); + + $after = $this->execute( 'get-form-action', array( 'id' => $created['id'] ) ); + $this->assertWPError( $after, 'A deleted action should no longer be found.' ); + $this->assertSame( 404, $after->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_list_form_actions_requires_a_form_id() { + $ability = wp_get_ability( 'formidable-forms/list-form-actions' ); + $result = $ability->execute( array() ); + + $this->assertWPError( $result, 'list-form-actions should refuse a request with no form_id, through the ability schema\'s own required check.' ); + } + + /** + * @return void + */ + public function test_create_form_action_requires_a_type() { + $ability = wp_get_ability( 'formidable-forms/create-form-action' ); + $result = $ability->execute( array( 'form_id' => $this->form->id ) ); + + $this->assertWPError( $result, 'create-form-action should refuse a request with no type, through the ability schema\'s own required check.' ); + } + + /** + * @return void + */ + public function test_create_form_action_rejects_an_unregistered_type() { + $result = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'not-a-real-action-type', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 400, $result->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_create_form_action_reports_not_found_for_a_missing_form() { + $result = $this->execute( + 'create-form-action', + array( + 'form_id' => 99999999, + 'type' => 'email', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null ); + } + + /** + * Post_content merges over the stored settings on update, so a partial + * change does not reset every other configured setting. + * + * @return void + */ + public function test_update_form_action_merges_post_content_rather_than_replacing_it() { + $created = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + 'post_content' => array( + 'email_to' => 'a@example.com', + 'email_subject' => 'Hello', + ), + ) + ); + $this->assertNotWPError( $created ); + + $updated = $this->execute( + 'update-form-action', + array( + 'id' => $created['id'], + 'post_content' => array( 'email_to' => 'b@example.com' ), + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( 'b@example.com', $updated['post_content']['email_to'] ?? null ); + $this->assertSame( 'Hello', $updated['post_content']['email_subject'] ?? null, 'A setting left out of the update should keep its stored value.' ); + } + + /** + * The type input is documented as a filter ("Filter by action type... + * Optional.") and execute_list_form_actions() attempts it by adding + * 'post_excerpt' => $type to the get_posts() args. That key is not one + * WP_Query filters on (unlike post_status, which is), so the argument is + * silently ignored and every action on the form comes back regardless of + * the requested type. Verified live against a real form with one email + * and one api action: filtering for type "api" still returns both. + * Expected to fail until the filter is implemented, e.g. with a + * 'meta_query'-free direct WHERE via 'post_excerpt__in' is not a real + * WP_Query arg either — the fix needs a manual $wpdb filter or a + * post-fetch array_filter() on post_excerpt. + * + * @return void + */ + public function test_list_form_actions_filters_by_type() { + $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + ) + ); + $webhook = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'api', + ) + ); + $this->assertNotWPError( $webhook ); + + $filtered = $this->execute( + 'list-form-actions', + array( + 'form_id' => $this->form->id, + 'type' => 'api', + ) + ); + + $this->assertNotWPError( $filtered ); + $this->assertCount( 1, $filtered, 'Filtering by type should return only the matching action.' ); + $this->assertArrayHasKey( $webhook['id'], $filtered ); + } + + /** + * @return void + */ + public function test_permission_follows_the_frm_view_and_edit_and_delete_forms_capabilities() { + $action_for_get = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + ) + ); + $action_for_update = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + ) + ); + $action_for_delete = $this->execute( + 'create-form-action', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + ) + ); + + $cases = array( + 'list-form-actions' => array( 'frm_view_forms', array( 'form_id' => $this->form->id ) ), + 'get-form-action' => array( 'frm_view_forms', array( 'id' => $action_for_get['id'] ) ), + 'create-form-action' => array( + 'frm_edit_forms', + array( + 'form_id' => $this->form->id, + 'type' => 'email', + ), + ), + 'update-form-action' => array( + 'frm_edit_forms', + array( + 'id' => $action_for_update['id'], + 'post_title' => 'X', + ), + ), + 'delete-form-action' => array( 'frm_delete_forms', array( 'id' => $action_for_delete['id'] ) ), + ); + + foreach ( $cases as $slug => list( $capability, $input ) ) { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + $denied = $ability->check_permissions( $input ); + $denied = is_wp_error( $denied ) ? false : $denied; + $this->assertFalse( $denied, $slug . ' should refuse a subscriber with no ' . $capability . ' capability.' ); + + $subscriber = get_user_by( 'id', $subscriber_id ); + $subscriber->add_cap( $capability ); + wp_set_current_user( 0 ); + wp_set_current_user( $subscriber_id ); + + $allowed = $ability->check_permissions( $input ); + $this->assertNotWPError( $allowed, $slug . ' should not error once ' . $capability . ' is granted.' ); + $this->assertTrue( $allowed, $slug . ' should allow a non-admin who holds ' . $capability . '.' ); + } + + $this->set_current_user_to_1(); + } +} diff --git a/tests/phpunit/entries/test_FrmAbilitiesEntriesController.php b/tests/phpunit/entries/test_FrmAbilitiesEntriesController.php new file mode 100644 index 0000000000..23ef7cd11c --- /dev/null +++ b/tests/phpunit/entries/test_FrmAbilitiesEntriesController.php @@ -0,0 +1,218 @@ +markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + $this->form = $this->factory->form->create_and_get(); + } + + /** + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @param string $slug Ability slug, without the formidable-forms prefix. + * @param array $input Ability input parameters. + * + * @return mixed + */ + private function execute( $slug, $input = array() ) { + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + + $this->assertInstanceOf( \WP_Ability::class, $ability, 'The ' . $slug . ' ability should be registered.' ); + + return $ability->execute( $input ); + } + + /** + * @return void + */ + public function test_get_and_delete_entry_round_trip() { + $entry_id = $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + $entry = $this->factory->entry->get_object_by_id( $entry_id ); + $fetched = $this->execute( 'get-entry', array( 'id' => $entry_id ) ); + $this->assertNotWPError( $fetched ); + $this->assertSame( (string) $entry_id, (string) $fetched['id'] ); + + $fetched_by_key = $this->execute( 'get-entry', array( 'id' => $entry->item_key ) ); + $this->assertNotWPError( $fetched_by_key, 'get-entry should also resolve by item_key.' ); + $this->assertSame( (string) $entry_id, (string) $fetched_by_key['id'] ); + + $deleted = $this->execute( 'delete-entry', array( 'id' => $entry_id ) ); + $this->assertNotWPError( $deleted ); + $this->assertSame( (string) $entry_id, (string) $deleted['id'] ); + + $after = $this->execute( 'get-entry', array( 'id' => $entry_id ) ); + $this->assertWPError( $after, 'A deleted entry should no longer be found.' ); + $this->assertSame( 404, $after->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_list_entries_scopes_to_a_form_when_given_one() { + $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + $other_form = $this->factory->form->create_and_get(); + $this->factory->entry->create( array( 'form_id' => $other_form->id ) ); + + $scoped = $this->execute( 'list-entries', array( 'form_id' => $this->form->id ) ); + + $this->assertNotWPError( $scoped ); + + foreach ( $scoped as $entry ) { + $this->assertSame( (string) $this->form->id, (string) $entry['form_id'], 'Every listed entry should belong to the requested form.' ); + } + } + + /** + * Listings include drafts unless is_draft is sent explicitly, since an + * ability is a data read, not the front-end submission flow that hides + * drafts by default. + * + * @return void + */ + public function test_list_entries_includes_drafts_by_default_and_can_be_filtered() { + $entry_data = $this->factory->field->generate_entry_array( $this->form ); + $entry_data['form_id'] = $this->form->id; + $entry_id = $this->factory->entry->create( $entry_data ); + + $draft_data = $entry_data; + $draft_data['is_draft'] = 1; + $draft_data['item_key'] = 'ability-entries-draft'; + $draft_id = $this->factory->entry->create( $draft_data ); + + $all = $this->execute( 'list-entries', array( 'form_id' => $this->form->id ) ); + $this->assertNotWPError( $all ); + $ids = wp_list_pluck( $all, 'id' ); + $this->assertContains( (string) $entry_id, array_map( 'strval', $ids ), 'Drafts should not hide the submitted entry.' ); + $this->assertContains( (string) $draft_id, array_map( 'strval', $ids ), 'Abilities should include drafts by default.' ); + + $submitted_only = $this->execute( + 'list-entries', + array( + 'form_id' => $this->form->id, + 'is_draft' => 0, + ) + ); + $this->assertNotWPError( $submitted_only ); + $submitted_ids = array_map( 'strval', wp_list_pluck( $submitted_only, 'id' ) ); + $this->assertContains( (string) $entry_id, $submitted_ids ); + $this->assertNotContains( (string) $draft_id, $submitted_ids, 'is_draft=0 should exclude drafts.' ); + + $drafts_only = $this->execute( + 'list-entries', + array( + 'form_id' => $this->form->id, + 'is_draft' => 1, + ) + ); + $this->assertNotWPError( $drafts_only ); + $draft_ids = array_map( 'strval', wp_list_pluck( $drafts_only, 'id' ) ); + $this->assertContains( (string) $draft_id, $draft_ids ); + $this->assertNotContains( (string) $entry_id, $draft_ids, 'is_draft=1 should exclude submitted entries.' ); + } + + /** + * @return void + */ + public function test_list_entries_order_accepts_any_casing() { + $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + + $results = array(); + + foreach ( array( 'asc', 'ASC', 'desc', 'DESC' ) as $order ) { + $listed = $this->execute( + 'list-entries', + array( + 'form_id' => $this->form->id, + 'order' => $order, + 'order_by' => 'id', + ) + ); + $results[ $order ] = wp_list_pluck( $listed, 'id' ); + } + + $this->assertSame( $results['asc'], $results['ASC'], 'ASC should sort the same as asc.' ); + $this->assertSame( $results['desc'], $results['DESC'], 'DESC should sort the same as desc.' ); + $this->assertSame( array_reverse( $results['asc'] ), $results['desc'], 'desc should return the reverse of asc.' ); + } + + /** + * @return void + */ + public function test_permission_follows_the_frm_view_and_delete_entries_capabilities() { + $entry_for_view = $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + $entry_for_delete = $this->factory->entry->create( array( 'form_id' => $this->form->id ) ); + + $cases = array( + 'list-entries' => array( 'frm_view_entries', array( 'form_id' => $this->form->id ) ), + 'get-entry' => array( 'frm_view_entries', array( 'id' => $entry_for_view ) ), + 'delete-entry' => array( 'frm_delete_entries', array( 'id' => $entry_for_delete ) ), + ); + + foreach ( $cases as $slug => list( $capability, $input ) ) { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + $denied = $ability->check_permissions( $input ); + $denied = is_wp_error( $denied ) ? false : $denied; + $this->assertFalse( $denied, $slug . ' should refuse a subscriber with no ' . $capability . ' capability.' ); + + $subscriber = get_user_by( 'id', $subscriber_id ); + $subscriber->add_cap( $capability ); + wp_set_current_user( 0 ); + wp_set_current_user( $subscriber_id ); + + $allowed = $ability->check_permissions( $input ); + $this->assertNotWPError( $allowed, $slug . ' should not error once ' . $capability . ' is granted.' ); + $this->assertTrue( $allowed, $slug . ' should allow a non-admin who holds ' . $capability . '.' ); + } + + $this->set_current_user_to_1(); + } +} diff --git a/tests/phpunit/fields/test_FrmAbilitiesFieldsController.php b/tests/phpunit/fields/test_FrmAbilitiesFieldsController.php new file mode 100644 index 0000000000..abfffe7aeb --- /dev/null +++ b/tests/phpunit/fields/test_FrmAbilitiesFieldsController.php @@ -0,0 +1,291 @@ +markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + $this->form = $this->factory->form->create_and_get(); + } + + /** + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @param string $slug Ability slug, without the formidable-forms prefix. + * @param array $input Ability input parameters. + * + * @return mixed + */ + private function execute( $slug, $input = array() ) { + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + + $this->assertInstanceOf( \WP_Ability::class, $ability, 'The ' . $slug . ' ability should be registered.' ); + + return $ability->execute( $input ); + } + + /** + * @return void + */ + public function test_a_field_round_trips_through_create_list_update_delete() { + $created = $this->execute( + 'create-field', + array( + 'form_id' => $this->form->id, + 'type' => 'text', + 'name' => 'Full Name', + 'required' => true, + ) + ); + + $this->assertNotWPError( $created, 'create-field should succeed with a form_id and a type.' ); + $this->assertSame( 'Full Name', $created['name'] ); + $this->assertSame( 'text', $created['type'] ); + $this->assertTrue( (bool) $created['required'] ); + + $listed = $this->execute( 'list-fields', array( 'form_id' => $this->form->id ) ); + $this->assertNotWPError( $listed ); + $this->assertArrayHasKey( $created['field_key'], $listed, 'The created field should be in the listing, keyed by field_key.' ); + + $updated = $this->execute( + 'update-field', + array( + 'id' => $created['id'], + 'name' => 'Renamed Field', + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( 'Renamed Field', $updated['name'] ); + + $deleted = $this->execute( 'delete-field', array( 'id' => $created['id'] ) ); + $this->assertNotWPError( $deleted ); + $this->assertSame( $created['id'], $deleted['id'] ); + + $after = $this->execute( 'list-fields', array( 'form_id' => $this->form->id ) ); + $this->assertNotWPError( $after ); + $this->assertArrayNotHasKey( $created['field_key'], $after, 'A deleted field should no longer be listed.' ); + } + + /** + * @return void + */ + public function test_list_fields_requires_a_form_id() { + $ability = wp_get_ability( 'formidable-forms/list-fields' ); + $result = $ability->execute( array() ); + + $this->assertWPError( $result, 'list-fields should refuse a request with no form_id, through the ability schema\'s own required check.' ); + } + + /** + * @return void + */ + public function test_create_field_requires_a_type() { + $ability = wp_get_ability( 'formidable-forms/create-field' ); + $result = $ability->execute( array( 'form_id' => $this->form->id ) ); + + $this->assertWPError( $result, 'create-field should refuse a request with no type, through the ability schema\'s own required check.' ); + } + + /** + * @return void + */ + public function test_create_field_reports_not_found_for_a_missing_form() { + $result = $this->execute( + 'create-field', + array( + 'form_id' => 99999999, + 'type' => 'text', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_update_field_reports_not_found_for_a_missing_id() { + $result = $this->execute( + 'update-field', + array( + 'id' => 99999999, + 'name' => 'X', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_delete_field_reports_not_found_for_a_missing_id() { + $result = $this->execute( 'delete-field', array( 'id' => 99999999 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null ); + } + + /** + * Form_id on delete-field is a guard, not a requirement: it only checks + * that the field named actually belongs to the form named, so a field + * that belongs to a different form should be refused rather than deleted. + * + * @return void + */ + public function test_delete_field_refuses_a_field_that_does_not_belong_to_the_given_form() { + $field = $this->factory->field->create_and_get( + array( + 'form_id' => $this->form->id, + 'type' => 'text', + ) + ); + $other_form = $this->factory->form->create_and_get(); + $result = $this->execute( + 'delete-field', + array( + 'id' => $field->id, + 'form_id' => $other_form->id, + ) + ); + + $this->assertWPError( $result, 'A field belonging to a different form than named should be refused.' ); + $this->assertSame( 404, $result->get_error_data()['status'] ?? null ); + + $this->assertNotNull( FrmField::getOne( $field->id ), 'The field should not have been deleted.' ); + } + + /** + * The placeholder property is not a real column: FrmField::update() only + * writes real columns, so update-field has to fold it into field_options + * itself or the value is silently dropped. + * + * @return void + */ + public function test_update_field_stores_the_placeholder_inside_field_options() { + $field = $this->factory->field->create_and_get( + array( + 'form_id' => $this->form->id, + 'type' => 'text', + ) + ); + $updated = $this->execute( + 'update-field', + array( + 'id' => $field->id, + 'placeholder' => 'Type here', + ) + ); + + $this->assertNotWPError( $updated ); + + $stored = FrmField::getOne( $field->id ); + $this->assertSame( 'Type here', $stored->field_options['placeholder'] ?? null, 'The placeholder should be stored inside field_options.' ); + } + + /** + * @return void + */ + public function test_permission_follows_the_frm_view_and_edit_and_delete_forms_capabilities() { + $cases = array( + 'list-fields' => array( 'frm_view_forms', array( 'form_id' => $this->form->id ) ), + 'create-field' => array( + 'frm_edit_forms', + array( + 'form_id' => $this->form->id, + 'type' => 'text', + ), + ), + 'update-field' => array( + 'frm_edit_forms', + array( + 'id' => $this->factory->field->create( + array( + 'form_id' => $this->form->id, + 'type' => 'text', + ) + ), + 'name' => 'X', + ), + ), + 'delete-field' => array( + 'frm_delete_forms', + array( + 'id' => $this->factory->field->create( + array( + 'form_id' => $this->form->id, + 'type' => 'text', + ) + ), + ), + ), + ); + + foreach ( $cases as $slug => list( $capability, $input ) ) { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + $denied = $ability->check_permissions( $input ); + $denied = is_wp_error( $denied ) ? false : $denied; + $this->assertFalse( $denied, $slug . ' should refuse a subscriber with no ' . $capability . ' capability.' ); + + $subscriber = get_user_by( 'id', $subscriber_id ); + $subscriber->add_cap( $capability ); + wp_set_current_user( 0 ); + wp_set_current_user( $subscriber_id ); + + $allowed = $ability->check_permissions( $input ); + $this->assertNotWPError( $allowed, $slug . ' should not error once ' . $capability . ' is granted.' ); + $this->assertTrue( $allowed, $slug . ' should allow a non-admin who holds ' . $capability . '.' ); + } + + $this->set_current_user_to_1(); + } +} diff --git a/tests/phpunit/forms/test_FrmAbilitiesFormsController.php b/tests/phpunit/forms/test_FrmAbilitiesFormsController.php new file mode 100644 index 0000000000..a9af8ec87d --- /dev/null +++ b/tests/phpunit/forms/test_FrmAbilitiesFormsController.php @@ -0,0 +1,280 @@ +markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + } + + /** + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @param string $slug Ability slug, without the formidable-forms prefix. + * @param array $input Ability input parameters. + * + * @return mixed + */ + private function execute( $slug, $input = array() ) { + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + + $this->assertInstanceOf( \WP_Ability::class, $ability, 'The ' . $slug . ' ability should be registered.' ); + + return $ability->execute( $input ); + } + + /** + * @return void + */ + public function test_a_form_round_trips_through_create_get_update_delete() { + $created = $this->execute( + 'create-form', + array( + 'name' => 'Ability Created Form', + 'description' => 'Made through create-form.', + ) + ); + + $this->assertNotWPError( $created, 'create-form should succeed with just a name.' ); + $this->assertSame( 'Ability Created Form', $created['name'] ); + $this->assertSame( 'published', $created['status'], 'A form with no status sent should default to published.' ); + $this->assertNotEmpty( $created['form_key'], 'A form key should be derived from the name.' ); + + $fetched = $this->execute( 'get-form', array( 'id' => $created['id'] ) ); + $this->assertNotWPError( $fetched ); + $this->assertSame( $created['id'], $fetched['id'] ); + + $fetched_by_key = $this->execute( 'get-form', array( 'id' => $created['form_key'] ) ); + $this->assertNotWPError( $fetched_by_key, 'get-form should also resolve by form_key.' ); + $this->assertSame( $created['id'], $fetched_by_key['id'] ); + + $updated = $this->execute( + 'update-form', + array( + 'id' => $created['id'], + 'name' => 'Renamed Form', + 'status' => 'draft', + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( 'Renamed Form', $updated['name'] ); + $this->assertSame( 'draft', $updated['status'] ); + $this->assertSame( 'Made through create-form.', $updated['description'], 'A field left out of the update should keep its value.' ); + + $deleted = $this->execute( 'delete-form', array( 'id' => $created['id'] ) ); + $this->assertNotWPError( $deleted ); + $this->assertSame( $created['id'], $deleted['id'] ); + + $after = $this->execute( 'get-form', array( 'id' => $created['id'] ) ); + $this->assertWPError( $after, 'A deleted form should no longer be found.' ); + $this->assertSame( 404, $after->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_create_form_requires_a_name() { + $ability = wp_get_ability( 'formidable-forms/create-form' ); + $result = $ability->execute( array() ); + + $this->assertWPError( $result, 'create-form should refuse an input with no name, through the ability schema\'s own required check.' ); + } + + /** + * The ability's own schema already enums status to published/draft/trash, + * so a value outside that set never reaches the controller through the + * ability layer. Called directly, to cover FrmAbilitiesFormsController::execute_update_form()'s + * own status check rather than the schema validation in front of it. + * + * @return void + */ + public function test_update_form_rejects_an_invalid_status() { + $form = $this->factory->form->create_and_get(); + $result = FrmAbilitiesFormsController::execute_update_form( + array( + 'id' => $form->id, + 'status' => 'not-a-real-status', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 400, $result->get_error_data()['status'] ?? null ); + } + + /** + * @return void + */ + public function test_update_form_requires_at_least_one_field_to_change() { + $form = $this->factory->form->create_and_get(); + $result = $this->execute( 'update-form', array( 'id' => $form->id ) ); + + $this->assertWPError( $result, 'update-form with nothing but an id should be refused rather than a silent no-op.' ); + $this->assertSame( 400, $result->get_error_data()['status'] ?? null ); + } + + /** + * Options are stored nested under $form['options'], but + * FrmFormsHelper::setup_new_vars() flattens them onto the top level, so + * create-form has to re-nest submitted options or they are silently + * dropped and every option falls back to its default. + * + * @return void + */ + public function test_create_form_applies_submitted_options() { + $created = $this->execute( + 'create-form', + array( + 'name' => 'Form With Options', + 'options' => array( 'submit_value' => 'Send it' ), + ) + ); + + $this->assertNotWPError( $created ); + + $form = FrmForm::getOne( $created['id'] ); + $this->assertSame( 'Send it', $form->options['submit_value'] ?? null, 'A submitted option should be applied, not dropped to its default.' ); + } + + /** + * A partial options update has to merge over what is stored, not replace + * it outright, or every option left out of the request would reset to + * its default and the form would lose its assigned style. + * + * @return void + */ + public function test_update_form_merges_options_rather_than_replacing_them() { + $created = $this->execute( + 'create-form', + array( + 'name' => 'Form With Two Options', + 'options' => array( + 'submit_value' => 'Send it', + 'success_msg' => 'Thanks!', + ), + ) + ); + $this->assertNotWPError( $created ); + + $updated = $this->execute( + 'update-form', + array( + 'id' => $created['id'], + 'options' => array( 'submit_value' => 'Go' ), + ) + ); + + $this->assertNotWPError( $updated ); + $form = FrmForm::getOne( $created['id'] ); + $this->assertSame( 'Go', $form->options['submit_value'] ?? null, 'The submitted option should be applied.' ); + $this->assertSame( 'Thanks!', $form->options['success_msg'] ?? null, 'An option left out of the update should keep its stored value.' ); + } + + /** + * Fields sent inline with create-form go through the same pipeline as + * create-field, so a form is never created half configured. + * + * @return void + */ + public function test_create_form_creates_inline_fields() { + $created = $this->execute( + 'create-form', + array( + 'name' => 'Form With Fields', + 'fields' => array( + array( + 'type' => 'text', + 'name' => 'Full Name', + ), + array( + 'type' => 'email', + 'name' => 'Email Address', + ), + ), + ) + ); + + $this->assertNotWPError( $created ); + + $fields = FrmField::get_all_for_form( $created['id'] ); + $this->assertCount( 2, $fields, 'Both inline fields should have been created.' ); + } + + /** + * @return void + */ + public function test_permission_follows_the_frm_view_and_edit_and_delete_forms_capabilities() { + $cases = array( + 'list-forms' => array( 'frm_view_forms', array() ), + 'get-form' => array( 'frm_view_forms', array( 'id' => $this->factory->form->create() ) ), + 'create-form' => array( 'frm_edit_forms', array( 'name' => 'X' ) ), + 'update-form' => array( + 'frm_edit_forms', + array( + 'id' => $this->factory->form->create(), + 'name' => 'X', + ), + ), + 'delete-form' => array( 'frm_delete_forms', array( 'id' => $this->factory->form->create() ) ), + ); + + foreach ( $cases as $slug => list( $capability, $input ) ) { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + $denied = $ability->check_permissions( $input ); + $denied = is_wp_error( $denied ) ? false : $denied; + $this->assertFalse( $denied, $slug . ' should refuse a subscriber with no ' . $capability . ' capability.' ); + + $subscriber = get_user_by( 'id', $subscriber_id ); + $subscriber->add_cap( $capability ); + wp_set_current_user( 0 ); + wp_set_current_user( $subscriber_id ); + + $allowed = $ability->check_permissions( $input ); + $this->assertNotWPError( $allowed, $slug . ' should not error once ' . $capability . ' is granted.' ); + $this->assertTrue( $allowed, $slug . ' should allow a non-admin who holds ' . $capability . '.' ); + } + + $this->set_current_user_to_1(); + } +} diff --git a/tests/phpunit/styles/test_FrmAbilitiesStylesController.php b/tests/phpunit/styles/test_FrmAbilitiesStylesController.php new file mode 100644 index 0000000000..797ec1b489 --- /dev/null +++ b/tests/phpunit/styles/test_FrmAbilitiesStylesController.php @@ -0,0 +1,234 @@ +markTestSkipped( 'The Abilities API is not available in this WordPress install.' ); + } + + $this->set_current_user_to_1(); + $this->enable_abilities(); + } + + /** + * @return void + */ + private function enable_abilities() { + if ( ! class_exists( 'WP_Abilities_Registry' ) || ! is_callable( 'FrmMcpController::reset' ) ) { + $this->markTestSkipped( 'This install has no abilities registry to register into.' ); + } + + $frm_settings = FrmAppHelper::get_settings(); + $frm_settings->mcp = 1; + $frm_settings->store(); + FrmMcpController::reset(); + + WP_Ability_Categories_Registry::get_instance(); + WP_Abilities_Registry::get_instance(); + + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); + + add_action( 'wp_abilities_api_categories_init', array( 'FrmAbilitiesController', 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( 'FrmAbilitiesController', 'register_abilities' ) ); + + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * @param string $slug Ability slug, without the formidable-forms prefix. + * @param array $input Ability input parameters. + * + * @return mixed + */ + private function execute( $slug, $input = array() ) { + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + + $this->assertInstanceOf( \WP_Ability::class, $ability, 'The ' . $slug . ' ability should be registered.' ); + + return $ability->execute( $input ); + } + + /** + * @return void + */ + public function test_get_style_resolves_the_default_style_by_keyword() { + $result = $this->execute( 'get-style', array( 'id' => 'default' ) ); + + $this->assertNotWPError( $result, 'get-style should resolve "default" to the site\'s default style.' ); + $this->assertNotEmpty( $result['post_name'] ); + } + + /** + * Both the ability's own description ("Retrieve a single Formidable + * style by ID or post_name.") and its id input's description ("Style ID + * or post_name.") advertise post_name lookup. This is currently false: + * FrmAbilitiesStylesController::get_style() does `new FrmStyle( $id )` + * for anything other than the literal string "default", and + * FrmStyle::get_one() resolves that id with a bare get_post( $this->id ), + * which only ever matches a numeric post ID, never a slug. Verified live + * against the real style post_name ("d3kj1b"), not guessed. The by-id + * and by-"default" assertions pass; the by-post_name assertion is + * expected to fail until get_style() adds a post_name lookup path (e.g. + * get_page_by_path() scoped to the style post type) the way the schema + * already promises. + * + * @return void + */ + public function test_get_style_resolves_by_id_or_post_name() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $by_id = $this->execute( 'get-style', array( 'id' => $default['id'] ) ); + $this->assertNotWPError( $by_id ); + $this->assertSame( $default['id'], $by_id['id'] ); + + $by_name = $this->execute( 'get-style', array( 'id' => $default['post_name'] ) ); + $this->assertNotWPError( $by_name, 'get-style should also resolve by post_name, as its own input schema documents.' ); + $this->assertSame( $default['id'], $by_name['id'] ); + } + + /** + * @return void + */ + public function test_list_styles_includes_the_default_style() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $listed = $this->execute( 'list-styles', array( 'page_size' => 200 ) ); + + $this->assertNotWPError( $listed ); + $this->assertArrayHasKey( $default['id'], $listed, 'The default style should appear in list-styles.' ); + } + + /** + * @return void + */ + public function test_update_style_changes_the_name_and_merges_settings() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $updated = $this->execute( + 'update-style', + array( + 'id' => $default['id'], + 'name' => 'Renamed Style', + 'post_content' => array( 'bg_color' => 'ff0000' ), + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( 'Renamed Style', $updated['name'] ); + $this->assertSame( 'ff0000', $updated['post_content']['bg_color'] ?? null ); + } + + /** + * A partial post_content update has to merge over the stored settings, + * not replace them, or every setting left out of the request resets. + * + * @return void + */ + public function test_update_style_leaves_other_settings_alone() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $this->execute( + 'update-style', + array( + 'id' => $default['id'], + 'post_content' => array( + 'bg_color' => 'ff0000', + 'text_color' => '00ff00', + ), + ) + ); + + $updated = $this->execute( + 'update-style', + array( + 'id' => $default['id'], + 'post_content' => array( 'bg_color' => '0000ff' ), + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( '0000ff', $updated['post_content']['bg_color'] ?? null ); + $this->assertSame( '00ff00', $updated['post_content']['text_color'] ?? null, 'A setting left out of the update should keep its stored value.' ); + } + + /** + * A hex color value stored with a leading # doubles up in the generated + * CSS (##ffffff), which the browser drops, so update-style has to strip + * it on the way in. + * + * @return void + */ + public function test_update_style_strips_a_leading_hash_from_hex_colors() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $updated = $this->execute( + 'update-style', + array( + 'id' => $default['id'], + 'post_content' => array( 'bg_color' => '#ff0000' ), + ) + ); + + $this->assertNotWPError( $updated ); + $this->assertSame( 'ff0000', $updated['post_content']['bg_color'] ?? null, 'A leading # should be stripped from a hex color value.' ); + } + + /** + * @return void + */ + public function test_permission_follows_the_frm_view_forms_and_frm_change_settings_capabilities() { + $default = $this->execute( 'get-style', array( 'id' => 'default' ) ); + $this->assertNotWPError( $default ); + + $cases = array( + 'list-styles' => array( 'frm_view_forms', array() ), + 'get-style' => array( 'frm_view_forms', array( 'id' => $default['id'] ) ), + 'update-style' => array( + 'frm_change_settings', + array( + 'id' => $default['id'], + 'name' => 'X', + ), + ), + ); + + foreach ( $cases as $slug => list( $capability, $input ) ) { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $ability = wp_get_ability( 'formidable-forms/' . $slug ); + $denied = $ability->check_permissions( $input ); + $denied = is_wp_error( $denied ) ? false : $denied; + $this->assertFalse( $denied, $slug . ' should refuse a subscriber with no ' . $capability . ' capability.' ); + + $subscriber = get_user_by( 'id', $subscriber_id ); + $subscriber->add_cap( $capability ); + wp_set_current_user( 0 ); + wp_set_current_user( $subscriber_id ); + + $allowed = $ability->check_permissions( $input ); + $this->assertNotWPError( $allowed, $slug . ' should not error once ' . $capability . ' is granted.' ); + $this->assertTrue( $allowed, $slug . ' should allow a non-admin who holds ' . $capability . '.' ); + } + + $this->set_current_user_to_1(); + } +}