<?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 );
}
}