[\d]+)', // Individual user access '/wp/v2/users/me', // Current user info // '/wp/v2/settings', // Site settings - dangerous '/wp/v2/application-passwords', // Application passwords '/jwt-auth', // JWT authentication routes '/oembed', // oEmbed routes ); /** * Route patterns to exclude (regex patterns), all methods. */ const FORBIDDEN_PATTERNS = array( '#^/wp-site-health#', // Site health '#^/wp/v2/users/(?P(?:[\d]+|me))/#', // User sub-routes // '#^/wp/v2/settings#', // All settings routes (now controlled per method) '#application-passwords#', // Any route touching application passwords '#/batch$#', // Batch endpoints '#/autosaves#', // Autosave routes '#/revisions#', // Revision routes ); /** * Route patterns that are forbidden only for specific HTTP methods. * This allows, for example, GET on plugins but blocks POST/DELETE. */ const FORBIDDEN_METHOD_PATTERNS = array( 'POST' => array( '#^/wp/v2/plugins#', '#^/wp/v2/themes#', // Ban POST on single user route: /wp/v2/users/(?P[\d]+) // Match the literal "(?P...)" part from the route pattern by escaping parentheses. // '#^/wp/v2/users/\(\?P.+$#', ), 'PUT' => array( '#^/wp/v2/plugins#', '#^/wp/v2/themes#', ), 'PATCH' => array( '#^/wp/v2/plugins#', '#^/wp/v2/themes#', ), 'DELETE' => array( '#^/wp/v2/plugins#', '#^/wp/v2/themes#', '#^/wp/v2/users#', '#^/wp/v2/posts#', '#/wp/v2/pages/#', '#/wp/v2/media/#', '#/wp/v2/menu-items/#', '#/wp/v2/blocks/#', '#/wp/v2/templates/#', '#/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/#', '#/wp/v2/navigation/#', '#/wp/v2/menus/#', '#/wp/v2/wp_pattern_category/#', '#/wp/v2/widgets/#' ), ); /** * Routes enabled by default when first discovered (method-agnostic). * For fine-grained control per HTTP method, see DEFAULT_ENABLED_METHOD_ROUTES. */ const DEFAULT_ENABLED_ROUTES = array( '/wp/v2/posts', '/wp/v2/posts/(?P[\d]+)', '/wp/v2/pages', '/wp/v2/pages/(?P[\d]+)', '/wp/v2/categories', '/wp/v2/tags', '/wp/v2/media', '/wp/v2/comments', '/wp/v2/search', '/wp/v2/media/(?P[\d]+)', '/wp/v2/menu-items', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/templates', '/wp/v2/template-parts', '/wp/v2/global-styles/', '/wp/v2/navigation/', '/wp/v2/font-families/', '/wp/v2/statuses', '/wp/v2/categories/', '/wp/v2/navigation', '/wp/v2/font-families', '/wp/v2/menus' ); /** * Routes enabled by default for specific HTTP methods. * Example: allow GET /wp/v2/settings by default, but not other methods. */ const DEFAULT_ENABLED_METHOD_ROUTES = array( 'GET' => array( '/wp/v2/settings', '/wp/v2/users', '/wp/v2/block-directory/search', '/wp/v2/pattern-directory/patterns', '/wp/v2/block-patterns/patterns', '/wp/v2/block-patterns/categories', '/wp/v2/menu-locations', '/wp/v2/menu-locations/(?P[\w-]+)', '/wp/v2/font-collections', '/wp/v2/font-collections/(?P[\/\w-]+)', ), 'POST' => array( '/wp/v2/users' ), ); /** * Namespaces to include in discovery */ const ALLOWED_NAMESPACES = array( 'wp/v2', 'wc/v3', 'wc/v2', 'wc-analytics', 'wp-block-editor/v1', 'ai-builder/v1', ); /** * Get all discoverable routes * * @return array */ public function get_all_routes() { $server = rest_get_server(); $routes = $server->get_routes(); $discovered = array(); foreach ($routes as $route => $handlers) { // Get route namespace $namespace = $this->extract_namespace($route); // Skip if namespace not in allowed list (but allow custom namespaces) if (empty($namespace)) { continue; } // Process each handler (method) for this route foreach ($handlers as $handler) { if (!isset($handler['methods']) || !isset($handler['callback'])) { continue; } $methods = array_keys($handler['methods']); foreach ($methods as $method) { // Skip forbidden route/method combinations if ($this->is_forbidden_route($route, $method)) { continue; } $route_info = $this->build_route_info($route, $method, $handler, $namespace); if ($route_info) { $discovered[] = $route_info; } } } } return $discovered; } /** * Get routes grouped by namespace * * @return array */ public function get_routes_by_namespace() { $routes = $this->get_all_routes(); $grouped = array(); foreach ($routes as $route) { $namespace = $route['namespace']; if (!isset($grouped[$namespace])) { $grouped[$namespace] = array(); } $grouped[$namespace][] = $route; } ksort($grouped); return $grouped; } /** * Convert routes to OpenAI/Anthropic tools format * * @param array $routes Array of route info * @return array Tools in JSON Schema format */ public function convert_to_tools_format(array $routes) { $tools = array(); foreach ($routes as $route) { $tool = array( 'name' => $this->generate_tool_name($route['route'], $route['method']), 'description' => $this->generate_tool_description($route), 'input_schema' => array( 'type' => 'object', 'properties' => $this->convert_args_to_schema($route['args']), 'required' => $this->get_required_args($route['args']), ), ); // Add route metadata for execution $tool['_meta'] = array( 'route' => $route['route'], 'method' => $route['method'], 'namespace' => $route['namespace'], ); $tools[] = $tool; } return $tools; } /** * Check if a route is forbidden * * @param string $route * @param string|null $method Optional HTTP method (GET, POST, etc.) * @return bool */ public function is_forbidden_route($route, $method = null) { // Check exact matches foreach (self::FORBIDDEN_ROUTES as $forbidden) { if ($route === $forbidden) { return true; } } // Check global patterns (all methods) foreach (self::FORBIDDEN_PATTERNS as $pattern) { if (preg_match($pattern, $route)) { return true; } } // Check method-specific patterns if method provided if (!empty($method)) { $method = strtoupper($method); if (isset(self::FORBIDDEN_METHOD_PATTERNS[$method])) { foreach (self::FORBIDDEN_METHOD_PATTERNS[$method] as $pattern) { if (preg_match($pattern, $route)) { return true; } } } } return false; } /** * Check if a route should be enabled by default * * @param string $route * @param string|null $method Optional HTTP method * @return bool */ public function is_default_enabled($route, $method = null) { // 1) Method-specific defaults if (!empty($method)) { $method = strtoupper($method); if (isset(self::DEFAULT_ENABLED_METHOD_ROUTES[$method])) { foreach (self::DEFAULT_ENABLED_METHOD_ROUTES[$method] as $default) { if ($route === $default) { return true; } // Also check if route matches the pattern $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#'; if (preg_match($pattern, $route)) { return true; } } } } // 2) Legacy method-agnostic defaults foreach (self::DEFAULT_ENABLED_ROUTES as $default) { if ($route === $default) { return true; } // Also check if route matches the pattern $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#'; if (preg_match($pattern, $route)) { return true; } } return false; } /** * Extract namespace from route * * @param string $route * @return string */ private function extract_namespace($route) { // Remove leading slash $route = ltrim($route, '/'); // Common patterns: wp/v2/posts, wc/v3/products if (preg_match('#^([a-zA-Z0-9_-]+/v\d+)#', $route, $matches)) { return $matches[1]; } // Single segment namespace if (preg_match('#^([a-zA-Z0-9_-]+)/#', $route, $matches)) { return $matches[1]; } return ''; } /** * Build route info array * * @param string $route * @param string $method * @param array $handler * @param string $namespace * @return array|null */ private function build_route_info($route, $method, $handler, $namespace) { $args = isset($handler['args']) ? $handler['args'] : array(); // Clean up args, remove internal WordPress args $cleaned_args = $this->clean_args($args); $upper_method = strtoupper($method); $route_info = array( 'route' => $route, 'method' => $upper_method, 'namespace' => $namespace, 'args' => $cleaned_args, 'permission_callback' => isset($handler['permission_callback']) ? true : false, ); // Always generate a short, human description for the UI and tools $route_info['description'] = $this->generate_tool_description($route_info); // Pass method so we can have method-specific defaults (e.g. GET /wp/v2/settings) $route_info['is_default_enabled'] = $this->is_default_enabled($route, $upper_method); $route_info['unique_id'] = $this->generate_route_id($route, $upper_method); return $route_info; } /** * Try to extract a human-readable description for a route from its handler. * * @param array $handler Route handler configuration. * @param string $route Route path. * @param string $method HTTP method. * @return string */ private function extract_route_description($handler, $route, $method) { // Some routes may expose a direct description field if (isset($handler['description']) && is_string($handler['description']) && $handler['description'] !== '') { return $handler['description']; } // Try schema description if schema is already resolved as an array if (isset($handler['schema']) && is_array($handler['schema']) && isset($handler['schema']['description'])) { $desc = $handler['schema']['description']; if (is_string($desc) && $desc !== '') { return $desc; } } // Fallback: empty string, UI will show a "Docs" button instead return ''; } /** * Clean arguments, remove internal WP args * * @param array $args * @return array */ private function clean_args($args) { $internal_args = array('context', '_fields', '_embed', '_envelope'); $cleaned = array(); foreach ($args as $key => $config) { if (in_array($key, $internal_args)) { continue; } $cleaned[$key] = $config; } return $cleaned; } /** * Convert WordPress REST args to JSON Schema properties * * @param array $args * @return array */ private function convert_args_to_schema($args) { $properties = array(); foreach ($args as $name => $config) { $property = array(); // Map WordPress types to JSON Schema types $wp_type = isset($config['type']) ? $config['type'] : 'string'; $property['type'] = $this->map_wp_type_to_json_schema($wp_type); // Add description if (isset($config['description'])) { $property['description'] = $config['description']; } // Handle enum if (isset($config['enum'])) { $property['enum'] = $config['enum']; } // Handle default if (isset($config['default'])) { $property['default'] = $config['default']; } // Handle array items if ($property['type'] === 'array' && isset($config['items'])) { $property['items'] = array( 'type' => $this->map_wp_type_to_json_schema($config['items']['type'] ?? 'string'), ); } // Handle minimum/maximum for integers if ($property['type'] === 'integer' || $property['type'] === 'number') { if (isset($config['minimum'])) { $property['minimum'] = $config['minimum']; } if (isset($config['maximum'])) { $property['maximum'] = $config['maximum']; } } // Handle pattern for strings if (isset($config['pattern'])) { $property['pattern'] = $config['pattern']; } $properties[$name] = $property; } return $properties; } /** * Map WordPress type to JSON Schema type * * @param string|array $wp_type * @return string */ private function map_wp_type_to_json_schema($wp_type) { // Handle array of types (e.g., ['string', 'null']) if (is_array($wp_type)) { // Return the first non-null type foreach ($wp_type as $type) { if ($type !== 'null') { return $this->map_wp_type_to_json_schema($type); } } return 'string'; } $type_map = array( 'string' => 'string', 'integer' => 'integer', 'int' => 'integer', 'number' => 'number', 'float' => 'number', 'boolean' => 'boolean', 'bool' => 'boolean', 'array' => 'array', 'object' => 'object', ); return isset($type_map[$wp_type]) ? $type_map[$wp_type] : 'string'; } /** * Get required arguments * * @param array $args * @return array */ private function get_required_args($args) { $required = array(); foreach ($args as $name => $config) { if (isset($config['required']) && $config['required']) { $required[] = $name; } } return $required; } /** * Generate a unique tool name from route and method * * @param string $route * @param string $method * @return string */ private function generate_tool_name($route, $method) { // Convert route to snake_case function name $name = $route; // Remove leading slash $name = ltrim($name, '/'); // Replace parameter patterns with generic names $name = preg_replace('/\(\?P<([^>]+)>[^)]+\)/', '$1', $name); // Replace slashes and special chars with underscores $name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name); // Remove consecutive underscores $name = preg_replace('/_+/', '_', $name); // Trim underscores $name = trim($name, '_'); // Add method prefix $method_prefix = strtolower($method); return $method_prefix . '_' . $name; } /** * Generate a unique route ID * * @param string $route * @param string $method * @return string */ public function generate_route_id($route, $method) { return md5($method . ':' . $route); } /** * Generate tool description * * @param array $route * @return string */ private function generate_tool_description($route) { $method = $route['method']; $path = $route['route']; // Parse route to create human-readable description $resource = $this->extract_resource_name($path); $descriptions = array( 'GET' => "Retrieve {$resource} from WordPress", 'POST' => "Create a new {$resource} in WordPress", 'PUT' => "Update an existing {$resource} in WordPress", 'PATCH' => "Partially update a {$resource} in WordPress", 'DELETE' => "Delete a {$resource} from WordPress", ); $base_description = isset($descriptions[$method]) ? $descriptions[$method] : "Perform {$method} operation on {$resource}"; return "{$base_description}"; } /** * Extract resource name from route path * * @param string $path * @return string */ private function extract_resource_name($path) { // Remove namespace and version $path = preg_replace('#^/[a-zA-Z0-9_-]+/v\d+/#', '', $path); // Remove parameter patterns $path = preg_replace('/\(\?P<[^>]+>[^)]+\)/', '', $path); // Get first segment as resource name $parts = explode('/', trim($path, '/')); $resource = isset($parts[0]) ? $parts[0] : 'resource'; // Make it singular and readable $resource = str_replace(array('-', '_'), ' ', $resource); return $resource; } }