$name, // Cast so an empty argument list encodes as {} and not []. 'arguments' => (object) $arguments, ) ); } /** * Send one JSON-RPC request to the MCP endpoint. * * @param string $key API key. * @param string $method JSON-RPC method. * @param array $params Method parameters. * @return array */ private static function rpc( $key, $method, $params ) { $response = wp_remote_post( YOPINGME_API_URL, array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', // The MCP SDK validates Accept and wants both values even // when the server is configured for plain JSON responses. 'Accept' => 'application/json, text/event-stream', 'Authorization' => 'Bearer ' . $key, ), 'body' => wp_json_encode( array( 'jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params, ) ), ) ); if ( is_wp_error( $response ) ) { return self::error( 'network', $response->get_error_message() ); } return self::parse_response( (int) wp_remote_retrieve_response_code( $response ), (string) wp_remote_retrieve_body( $response ) ); } /** * Whether the server offers a given tool. * * The plugin can be newer than the YoPingMe it is talking to, so a feature * is offered only once the server says it exists. Listing tools costs no * daily query, but it is still a round trip, so the answer is cached. * * @param string $name Tool name. * @return bool */ public static function supports_tool( $name ) { $tools = get_transient( 'yopingme_tools' ); if ( ! is_array( $tools ) ) { $key = (string) get_option( 'yopingme_api_key', '' ); if ( '' === $key ) { return false; } $result = self::rpc( $key, 'tools/list', array() ); if ( empty( $result['ok'] ) || ! isset( $result['data']['tools'] ) ) { // Unknown rather than absent: do not cache a failure. return false; } $tools = array(); foreach ( (array) $result['data']['tools'] as $tool ) { if ( isset( $tool['name'] ) ) { $tools[] = (string) $tool['name']; } } set_transient( 'yopingme_tools', $tools, HOUR_IN_SECONDS ); } return in_array( $name, $tools, true ); } /** * Whether this site is reachable from the outside at all. * * YoPingMe checks from probes on the public internet, so asking it to * watch localhost or a private address would create a monitor that can * only ever report down. Better to refuse than to hand someone a * permanently red dashboard. * * @param string $url Site URL. * @return bool */ public static function is_public_url( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host ) { return false; } $host = strtolower( $host ); if ( 'localhost' === $host || '.localhost' === substr( $host, -10 ) ) { return false; } // Reserved development and internal names that never resolve publicly. foreach ( array( '.local', '.test', '.example', '.invalid', '.internal' ) as $suffix ) { if ( substr( $host, -strlen( $suffix ) ) === $suffix ) { return false; } } // A bare hostname with no dot cannot be a public domain. if ( false === strpos( $host, '.' ) && ! filter_var( $host, FILTER_VALIDATE_IP ) ) { return false; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return (bool) filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } return true; } /** * Turn an HTTP status and body into a result or a classified error. * * Kept free of WordPress calls other than translation so it can be * exercised by tests/parse-response-test.php. * * @param int $status HTTP status code. * @param string $body Raw response body. * @return array {ok: bool, data?: array, error?: array} */ public static function parse_response( $status, $body ) { if ( 401 === $status ) { return self::error( 'auth', __( 'The API key was not accepted.', 'yopingme' ) ); } if ( 200 !== $status ) { return self::error( 'http_' . $status, sprintf( /* translators: %d: HTTP status code. */ __( 'YoPingMe answered with HTTP %d.', 'yopingme' ), $status ) ); } $decoded = json_decode( $body, true ); if ( ! is_array( $decoded ) ) { return self::error( 'bad_response', __( 'YoPingMe sent a response we could not read.', 'yopingme' ) ); } if ( isset( $decoded['error'] ) ) { $message = isset( $decoded['error']['message'] ) ? (string) $decoded['error']['message'] : ''; // A YoPingMe older than this plugin simply will not have a tool the // plugin knows about. That is a missing feature, not a fault, and // the caller should be able to say so in plain language. if ( false !== stripos( $message, 'unknown tool' ) ) { return self::error( 'unknown_tool', $message ); } return self::error( 'bad_response', '' !== $message ? $message : __( 'YoPingMe rejected the request.', 'yopingme' ) ); } if ( ! isset( $decoded['result'] ) || ! is_array( $decoded['result'] ) ) { return self::error( 'bad_response', __( 'YoPingMe sent a response we could not read.', 'yopingme' ) ); } $result = $decoded['result']; $text = self::content_text( $result ); if ( ! empty( $result['isError'] ) ) { // Tool errors carry a JSON payload as their text content: // {"code":"rate_limited","resets_at":"...","message":"..."}. $payload = json_decode( $text, true ); if ( is_array( $payload ) && isset( $payload['code'] ) ) { return self::error( (string) $payload['code'], isset( $payload['message'] ) ? (string) $payload['message'] : $text, isset( $payload['resets_at'] ) ? (string) $payload['resets_at'] : '' ); } return self::error( 'tool_error', '' !== $text ? $text : __( 'YoPingMe could not answer that.', 'yopingme' ) ); } if ( isset( $result['structuredContent'] ) && is_array( $result['structuredContent'] ) ) { return array( 'ok' => true, 'data' => $result['structuredContent'] ); } $payload = json_decode( $text, true ); if ( is_array( $payload ) ) { return array( 'ok' => true, 'data' => $payload ); } // Results that are not tool calls - tools/list and friends - carry their // payload directly rather than in content blocks. if ( ! isset( $result['content'] ) ) { return array( 'ok' => true, 'data' => $result ); } return self::error( 'bad_response', __( 'YoPingMe sent a response we could not read.', 'yopingme' ) ); } /** * Concatenate the text content blocks of a tool result. * * @param array $result Tool result. * @return string */ private static function content_text( $result ) { if ( empty( $result['content'] ) || ! is_array( $result['content'] ) ) { return ''; } $parts = array(); foreach ( $result['content'] as $block ) { if ( is_array( $block ) && isset( $block['text'] ) ) { $parts[] = (string) $block['text']; } } return implode( '', $parts ); } /** * Build an error result. * * @param string $code Error code. * @param string $message Human-readable message. * @param string $resets_at RFC 3339 time the quota resets, when known. * @return array */ private static function error( $code, $message, $resets_at = '' ) { return array( 'ok' => false, 'error' => array( 'code' => $code, 'message' => $message, 'resets_at' => $resets_at, ), ); } }