Skip to main content
Glama
RegisterMcpTool.php14.6 kB
<?php //phpcs:ignore declare(strict_types=1); namespace Automattic\WordpressMcp\Core; use InvalidArgumentException; /** * Register an MCP tool. */ class RegisterMcpTool { /** * The arguments. * * @var array */ private array $args; /** * Constructor. * * @param array $args The arguments to register the MCP tool. * @throws InvalidArgumentException|\RuntimeException When the arguments are invalid or tool is registered outside of wordpress_mcp_init action. */ public function __construct( array $args ) { if ( ! doing_action( 'wordpress_mcp_init' ) ) { throw new \RuntimeException( 'RegisterMcpTool can only be used within the wordpress_mcp_init action.' ); } $this->args = $args; // Backward compatibility for permissions_callback. if ( isset( $this->args['permissions_callback'] ) ) { $this->args['permission_callback'] = $this->args['permissions_callback']; unset( $this->args['permissions_callback'] ); } $this->validate_arguments(); $this->register_tool(); } /** * Register the tool. * * @return void */ private function register_tool(): void { if ( ! empty( $this->args['rest_alias'] ) ) { $this->get_args_from_rest_api(); } else { WPMCP()->register_tool( $this->args ); } } /** * Get the arguments from the rest api. * * @return void * @throws InvalidArgumentException When the REST API route or method is invalid. */ private function get_args_from_rest_api(): void { $method = $this->args['rest_alias']['method']; $route = $this->args['rest_alias']['route']; // get a list of all registered rest routes. $routes = rest_get_server()->get_routes(); $rest_route = $routes[ $route ] ?? null; if ( ! $rest_route ) { McpErrorHandler::log_error( 'The route does not exist: ' . $route . ' ' . $method . ' Skipping registration.' ); // Skip registration if the route doesn't exist. return; } $rest_api = null; // the subarray should contain the method. foreach ( $rest_route as $endpoint ) { if ( isset( $endpoint['methods'][ $method ] ) && true === $endpoint['methods'][ $method ] ) { $rest_api = $endpoint; break; } } if ( ! $rest_api ) { McpErrorHandler::log_error( 'The method does not exist: ' . $method . ' in ' . $route . ' Skipping registration.' ); return; } // Convert REST API args to MCP input schema. $input_schema = array( 'type' => 'object', 'properties' => array(), 'required' => array(), ); foreach ( $rest_api['args'] as $arg_name => $arg_schema ) { if ( ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $arg_name ) ) { // log the invalid parameter name. McpErrorHandler::log_error( 'Invalid parameter name: ' . $arg_name . ' in ' . $route . ' ' . $method . '. The parameter was skipped.' ); continue; // Skip invalid parameter names. } $type = $arg_schema['type']; if ( is_array( $type ) ) { $type = reset( $type ); } $input_schema['properties'][ $arg_name ] = array( 'type' => $type, 'description' => $this->get_description_with_fallback( $arg_schema['description'], $arg_name ), ); // Handle array items if present. if ( isset( $arg_schema['items'] ) ) { $input_schema['properties'][ $arg_name ]['items'] = $arg_schema['items']; } // Handle enums if present and remove duplicates. if ( isset( $arg_schema['enum'] ) ) { $input_schema['properties'][ $arg_name ]['enum'] = array_values( array_unique( $arg_schema['enum'], SORT_REGULAR ) ); } // Handle default values if present. if ( isset( $arg_schema['default'] ) && ! empty( $arg_schema['default'] ) ) { $input_schema['properties'][ $arg_name ]['default'] = $arg_schema['default']; } // Handle format if present. if ( isset( $arg_schema['format'] ) ) { $input_schema['properties'][ $arg_name ]['format'] = $arg_schema['format']; } // Handle minimum/maximum if present. if ( isset( $arg_schema['minimum'] ) ) { $input_schema['properties'][ $arg_name ]['minimum'] = $arg_schema['minimum']; } if ( isset( $arg_schema['maximum'] ) ) { $input_schema['properties'][ $arg_name ]['maximum'] = $arg_schema['maximum']; } // If the parameter has no default value and is not explicitly optional, mark it as required. if ( isset( $arg_schema['required'] ) && true === $arg_schema['required'] ) { $input_schema['required'][] = $arg_name; } } // Convert required array to object. if ( empty( $input_schema['properties'] ) ) { unset( $input_schema['properties'] ); } if ( empty( $input_schema['required'] ) ) { unset( $input_schema['required'] ); } // Apply modifications if provided in rest_alias['modifications'] . if ( isset( $this->args['rest_alias']['inputSchemaReplacements'] ) ) { $modifications = $this->args['rest_alias']['inputSchemaReplacements']; $input_schema = $this->apply_modifications( $input_schema, $modifications ); // Ensure required field is always an array if it exists. if ( isset( $input_schema['required'] ) && ! is_array( $input_schema['required'] ) ) { // Convert to array if it's not already. if ( is_object( $input_schema['required'] ) ) { $input_schema['required'] = array_values( (array) $input_schema['required'] ); } else { $input_schema['required'] = array(); } } } // Update the args with the converted schema. $this->args['inputSchema'] = $input_schema; $this->args['callback'] = $rest_api['callback']; $this->args['permission_callback'] = $rest_api['permission_callback']; // Register the tool with the converted schema. WPMCP()->register_tool( $this->args ); } /** * Validate the arguments. * * @return void * @throws InvalidArgumentException When the arguments are invalid. */ private function validate_arguments(): void { // name is required. if ( ! isset( $this->args['name'] ) ) { throw new InvalidArgumentException( 'The name is required.' ); } // validate the name: must be a string and between 1 and 64 characters. if ( ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $this->args['name'] ) ) { throw new InvalidArgumentException( 'The name must be a string between 1 and 64 characters.' ); } // description is required. if ( ! isset( $this->args['description'] ) ) { throw new InvalidArgumentException( 'The description is required.' ); } // functionality_type is required. if ( ! isset( $this->args['type'] ) ) { throw new InvalidArgumentException( 'The functionality type is required.' ); } // validate functionality type: must be one of 'create', 'read', 'update', 'delete', 'action'. $valid_types = array( 'create', 'read', 'update', 'delete', 'action' ); if ( ! in_array( $this->args['type'], $valid_types, true ) ) { throw new InvalidArgumentException( 'The functionality type must be one of: ' . esc_html( implode( ', ', $valid_types ) ) ); } // if rest_alias is provided, the rest of the arguments are not required. if ( isset( $this->args['rest_alias'] ) ) { $this->validate_rest_alias(); return; } // callback is required. if ( ! isset( $this->args['callback'] ) ) { throw new InvalidArgumentException( 'The callback is required.' ); } // callback must be callable. if ( ! is_callable( $this->args['callback'] ) ) { throw new InvalidArgumentException( 'The callback must be a callable.' ); } // permission_callback must be callable. if ( empty( $this->args['permission_callback'] ) ) { throw new InvalidArgumentException( 'The permission callback is required.' ); } // permission_callback must be callable. if ( ! is_callable( $this->args['permission_callback'] ) ) { throw new InvalidArgumentException( 'The permission callback must be a callable.' ); } // validate the input schema. $this->validate_input_schema(); } /** * Validate the rest api alias. * * @return void * @throws InvalidArgumentException When the rest api alias is invalid. */ private function validate_rest_alias(): void { // route is required. if ( ! isset( $this->args['rest_alias']['route'] ) ) { throw new InvalidArgumentException( 'The route is required.' ); } // method is required. if ( ! isset( $this->args['rest_alias']['method'] ) ) { throw new InvalidArgumentException( 'The method is required.' ); } // validate the method: must be one of the following: GET, POST, PUT, PATCH, DELETE. if ( ! in_array( $this->args['rest_alias']['method'], array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) { throw new InvalidArgumentException( 'The method must be one of the following: GET, POST, PUT, PATCH, DELETE.' ); } } /** * Get description with fallback when null or empty. * * @param string|null $description The original description. * @param string $arg_name The argument name to use as fallback. * @return string The description with fallback. */ private function get_description_with_fallback( ?string $description, string $arg_name ): string { // If description exists and is not empty, return it. if ( ! empty( $description ) ) { return $description; } // If description is null or empty, create a meaningful fallback. // Use the tool name and argument name to create a descriptive text. $tool_name = $this->args['name'] ?? 'tool'; return "Parameter '{$arg_name}' for {$tool_name}"; } /** * Validate the input schema. * * @return void * @throws InvalidArgumentException When the input schema is invalid. */ private function validate_input_schema(): void { // Check if the input schema is provided. if ( empty( $this->args['inputSchema'] ) ) { throw new InvalidArgumentException( 'The input schema is required.' ); } // Validate that the input schema is a valid JSON Schema object. if ( ! isset( $this->args['inputSchema']['type'] ) || 'object' !== $this->args['inputSchema']['type'] ) { throw new InvalidArgumentException( esc_html__( 'The input schema must be an object type.', 'wordpress-mcp' ) ); } // Validate properties field if present (it's optional according to MCP spec). if ( isset( $this->args['inputSchema']['properties'] ) ) { if ( ! is_array( $this->args['inputSchema']['properties'] ) && ! is_object( $this->args['inputSchema']['properties'] ) ) { throw new InvalidArgumentException( esc_html__( 'The input schema properties field must be an array or an object.', 'wordpress-mcp' ) ); } // Validate each property has a type. foreach ( $this->args['inputSchema']['properties'] as $property_name => $property ) { // Validate property name format matches MCP requirements. if ( ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $property_name ) ) { // translators: %s: Property name. throw new InvalidArgumentException( sprintf( esc_html__( "Property name '%s' must match pattern '^[a-zA-Z0-9_-]{1,64}$'.", 'wordpress-mcp' ), esc_html( $property_name ) ) ); } if ( ! isset( $property['type'] ) ) { // translators: %s: Property name. throw new InvalidArgumentException( sprintf( esc_html__( "Property '%s' must have a type field.", 'wordpress-mcp' ), esc_html( $property_name ) ) ); } // Validate property type is a valid JSON Schema type. $valid_types = array( 'string', 'number', 'integer', 'boolean', 'array', 'object', 'null' ); if ( ! in_array( $property['type'], $valid_types, true ) ) { // translators: 1: Property name, 2: Property type. throw new InvalidArgumentException( sprintf( esc_html__( "Property '%1\$s' has invalid type '%2\$s'.", 'wordpress-mcp' ), esc_html( $property_name ), esc_html( $property['type'] ) ) ); } // If the type is array, validate that items field exists. if ( 'array' === $property['type'] && ! isset( $property['items'] ) ) { // translators: %s: Property name. throw new InvalidArgumentException( sprintf( esc_html__( "Array property '%s' must have an items field.", 'wordpress-mcp' ), esc_html( $property_name ) ) ); } } } // Validate the required field if present (it's optional according to MCP spec). if ( isset( $this->args['inputSchema']['required'] ) ) { // Ensure required field is an array. if ( ! is_array( $this->args['inputSchema']['required'] ) ) { throw new InvalidArgumentException( esc_html__( 'The required field must be an array.', 'wordpress-mcp' ) ); } // Validate required field values are non-empty strings. foreach ( $this->args['inputSchema']['required'] as $required_property ) { if ( ! is_string( $required_property ) || empty( $required_property ) ) { throw new InvalidArgumentException( esc_html__( 'Required field names must be non-empty strings.', 'wordpress-mcp' ) ); } } // Check all required properties exist in properties (only if properties field exists). if ( isset( $this->args['inputSchema']['properties'] ) ) { foreach ( $this->args['inputSchema']['required'] as $required_property ) { if ( ! isset( $this->args['inputSchema']['properties'][ $required_property ] ) ) { // translators: %s: Required property. throw new InvalidArgumentException( sprintf( esc_html__( "Required property '%s' does not exist in properties.", 'wordpress-mcp' ), esc_html( $required_property ) ) ); } } } else { // If properties doesn't exist but required does, that's an error. throw new InvalidArgumentException( esc_html__( 'Cannot have required fields without a properties definition.', 'wordpress-mcp' ) ); } } } /** * Recursively remove all null values from an array. * * @param array $data The array to clean. * @return array The cleaned array. */ private function remove_null_recursive( array $data ): array { foreach ( $data as $key => &$value ) { if ( is_array( $value ) ) { $value = $this->remove_null_recursive( $value ); } elseif ( is_null( $value ) ) { unset( $data[ $key ] ); } } unset( $value ); // break reference. return $data; } /** * Apply modifications to the input schema. * * @param array $input_schema The input schema. * @param array $modifications The modifications to apply. * @return array The modified input schema. */ private function apply_modifications( array $input_schema, array $modifications ): array { $result = array_replace_recursive( $input_schema, $modifications ); // Ensure required field is always an array if it exists. if ( isset( $result['required'] ) && ! is_array( $result['required'] ) ) { $result['required'] = array(); } return $this->remove_null_recursive( $result ); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Automattic/wordpress-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server