*/ public static function list(): array { $out = []; foreach ( self::abilities() as $ability ) { $schema = $ability->get_input_schema(); $out[] = [ 'name' => self::tool_name( $ability->get_name() ), 'description' => $ability->get_description(), 'inputSchema' => ! empty( $schema ) ? self::normalize_schema( $schema ) : [ 'type' => 'object', 'properties' => (object) [], ], ]; } return $out; } /** * Normalize a JSON Schema for MCP clients: an empty PHP `properties` array * JSON-encodes as `[]`, but the schema spec (and the MCP SDK's validator) * requires an OBJECT — `{}`. Recurse first so nested object schemas (e.g. * a no-field `settings` sub-object) are fixed too. * * @param array $schema JSON Schema node. * @return array */ private static function normalize_schema( array $schema ): array { foreach ( $schema as $key => $value ) { if ( is_array( $value ) ) { $schema[ $key ] = self::normalize_schema( $value ); } } if ( isset( $schema['properties'] ) && [] === $schema['properties'] ) { $schema['properties'] = (object) []; } return $schema; } /** * Invoke a tool by name with decoded arguments. The ability's own input * validation and permission callback run inside WP_Ability::execute() * (the authenticated credential's user is already set by Mcp_Server). * * @param string $name Tool name (ability name sans `thinkrank/` prefix). * @param array $args Decoded arguments. * @return mixed|\WP_Error Result payload or error. */ public static function invoke( string $name, array $args ) { $ability = null; if ( function_exists( 'wp_get_ability' ) ) { foreach ( self::ABILITY_PREFIXES as $prefix ) { $candidate = wp_get_ability( $prefix . $name ); if ( $candidate ) { $ability = $candidate; break; } } } if ( ! $ability ) { return new \WP_Error( 'thinkrank_mcp_unknown_tool', sprintf( /* translators: %s: tool name. */ __( 'Unknown tool: %s', 'thinkrank' ), $name ), [ 'status' => 404 ] ); } // Scope enforcement: a read-only connection cannot invoke a tool that // mutates state. if ( self::is_write_tool( $name ) && self::is_read_only() ) { return new \WP_Error( 'thinkrank_mcp_read_only', sprintf( /* translators: %s: tool name. */ __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'thinkrank' ), $name ), [ 'status' => 403 ] ); } return $ability->execute( $args ); } /** * Whether a tool mutates state. Read tools are `get-*` / `list-*`; * everything else is treated as write. * * @param string $name Tool name (sans prefix). * @return bool */ public static function is_write_tool( string $name ): bool { foreach ( self::READ_PREFIXES as $prefix ) { if ( 0 === strpos( $name, $prefix ) ) { return false; } } return true; } /** * Map an ability name to its MCP tool name (strip the category prefix — * MCP tool names may not contain `/`). * * @param string $ability_name Full ability name, e.g. `thinkrank/get-post-seo`. * @return string */ private static function tool_name( string $ability_name ): string { $bare = self::strip_prefix( $ability_name ); if ( null !== $bare ) { return $bare; } return str_replace( '/', '-', $ability_name ); } /** * All registered ThinkRank abilities. * * @return \WP_Ability[] */ private static function abilities(): array { if ( ! function_exists( 'wp_get_abilities' ) ) { return []; } // Our abilities reach the registry through `wp_abilities_api_init`, // which fires once from whichever Abilities API copy owns the global // functions. When a foreign copy owns them our callback can be missed // entirely, leaving this filter with nothing to match and the client // with a connected-but-empty tool list (#241). This replays the // registration once, and is a no-op on a healthy request. Abilities_Registrar::ensure_registered(); $out = []; foreach ( wp_get_abilities() as $ability ) { if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) { continue; } if ( null !== self::strip_prefix( $ability->get_name() ) ) { $out[] = $ability; } } return $out; } /** * Strip a recognized ThinkRank ability prefix, returning the bare tool name. * Returns null when the ability is not one of ours (so callers can filter). * * @param string $ability_name Full ability name, e.g. `thinkrank-pro/get-redirects`. * @return string|null Bare name (`get-redirects`) or null if not a ThinkRank ability. */ private static function strip_prefix( string $ability_name ): ?string { foreach ( self::ABILITY_PREFIXES as $prefix ) { if ( 0 === strpos( $ability_name, $prefix ) ) { return substr( $ability_name, strlen( $prefix ) ); } } return null; } }