| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP connection self-test. |
| 4 |
* |
| 5 |
* @package ThinkRank\Mcp |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Mcp; |
| 11 |
|
| 12 |
if ( ! defined( 'ABSPATH' ) ) { |
| 13 |
exit; // Exit if accessed directly. |
| 14 |
} |
| 15 |
|
| 16 |
/** |
| 17 |
* Exercises the MCP round trip the way an external client would and reports |
| 18 |
* *where* it broke, so an admin can tell a certificate problem from an |
| 19 |
* authentication problem from an ability-discovery problem without leaving the |
| 20 |
* MCP page (see #189). |
| 21 |
* |
| 22 |
* It performs one real loopback `tools/list` call against this site's own MCP |
| 23 |
* endpoint using the active connection token. The staged result names the first |
| 24 |
* failing step: `disabled` → `not_connected` → `unreachable` → `tls` → |
| 25 |
* `redirect` → `auth` → `no_tools` → `ok`. |
| 26 |
*/ |
| 27 |
final class Mcp_Self_Test { |
| 28 |
|
| 29 |
/** |
| 30 |
* Run the round-trip self-test. |
| 31 |
* |
| 32 |
* @return array<string, mixed> |
| 33 |
*/ |
| 34 |
public static function run(): array { |
| 35 |
$endpoint = Mcp_Pairing::site_endpoint_fallback(); |
| 36 |
|
| 37 |
$result = [ |
| 38 |
'ok' => false, |
| 39 |
'stage' => '', |
| 40 |
'message' => '', |
| 41 |
'endpoint' => $endpoint, |
| 42 |
'mcp_enabled' => Mcp_Manager::is_enabled(), |
| 43 |
'connected' => Mcp_Pairing::is_connected(), |
| 44 |
'http_status' => null, |
| 45 |
'redirected' => false, |
| 46 |
'authenticated' => false, |
| 47 |
'tools_count' => null, |
| 48 |
]; |
| 49 |
|
| 50 |
if ( ! $result['mcp_enabled'] ) { |
| 51 |
$result['stage'] = 'disabled'; |
| 52 |
$result['message'] = __( 'MCP access is turned off, so the endpoint refuses every request. Enable MCP access above and try again.', 'thinkrank' ); |
| 53 |
return $result; |
| 54 |
} |
| 55 |
|
| 56 |
if ( ! $result['connected'] ) { |
| 57 |
$result['stage'] = 'not_connected'; |
| 58 |
$result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' ); |
| 59 |
return $result; |
| 60 |
} |
| 61 |
|
| 62 |
$response = wp_remote_post( |
| 63 |
$endpoint, |
| 64 |
[ |
| 65 |
'timeout' => 10, |
| 66 |
// Don't follow redirects: a 301/302 here IS the finding (the |
| 67 |
// classic http<->https scheme bounce), so surface it verbatim. |
| 68 |
'redirection' => 0, |
| 69 |
'headers' => [ |
| 70 |
'Authorization' => 'Bearer ' . Mcp_Pairing::site_token(), |
| 71 |
'Content-Type' => 'application/json', |
| 72 |
'Accept' => 'application/json', |
| 73 |
], |
| 74 |
'body' => wp_json_encode( |
| 75 |
[ |
| 76 |
'jsonrpc' => '2.0', |
| 77 |
'id' => 1, |
| 78 |
'method' => 'tools/list', |
| 79 |
] |
| 80 |
), |
| 81 |
] |
| 82 |
); |
| 83 |
|
| 84 |
if ( is_wp_error( $response ) ) { |
| 85 |
$err = $response->get_error_message(); |
| 86 |
$is_tls = false !== stripos( $err, 'ssl' ) || false !== stripos( $err, 'certificate' ); |
| 87 |
$result['stage'] = $is_tls ? 'tls' : 'unreachable'; |
| 88 |
$result['message'] = $is_tls |
| 89 |
/* translators: %s: underlying transport error. */ |
| 90 |
? sprintf( __( 'The endpoint could not be reached over HTTPS: %s. On local/dev sites this is usually a self-signed certificate the AI client must be told to trust.', 'thinkrank' ), $err ) |
| 91 |
/* translators: %s: underlying transport error. */ |
| 92 |
: sprintf( __( 'The endpoint could not be reached: %s.', 'thinkrank' ), $err ); |
| 93 |
return $result; |
| 94 |
} |
| 95 |
|
| 96 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 97 |
$result['http_status'] = $status; |
| 98 |
|
| 99 |
if ( in_array( $status, [ 301, 302, 307, 308 ], true ) ) { |
| 100 |
$location = (string) wp_remote_retrieve_header( $response, 'location' ); |
| 101 |
$result['redirected'] = true; |
| 102 |
$result['stage'] = 'redirect'; |
| 103 |
$result['message'] = $location |
| 104 |
/* translators: %s: redirect target URL. */ |
| 105 |
? sprintf( __( 'The endpoint redirected to %s instead of answering. A redirect between HTTP and HTTPS usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $location ) |
| 106 |
: __( 'The endpoint redirected instead of answering, which usually means the site address and WordPress address schemes disagree.', 'thinkrank' ); |
| 107 |
return $result; |
| 108 |
} |
| 109 |
|
| 110 |
if ( 401 === $status || 403 === $status ) { |
| 111 |
$result['stage'] = 'auth'; |
| 112 |
$result['message'] = __( 'The endpoint rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'thinkrank' ); |
| 113 |
return $result; |
| 114 |
} |
| 115 |
|
| 116 |
$result['authenticated'] = true; |
| 117 |
$body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); |
| 118 |
$tools = ( is_array( $body ) && isset( $body['result']['tools'] ) && is_array( $body['result']['tools'] ) ) |
| 119 |
? $body['result']['tools'] |
| 120 |
: null; |
| 121 |
|
| 122 |
if ( 200 !== $status || null === $tools ) { |
| 123 |
$result['stage'] = 'no_tools'; |
| 124 |
$result['message'] = __( 'The endpoint answered but returned no tool catalog. Confirm the MCP runtime is built and abilities are registered.', 'thinkrank' ); |
| 125 |
$result['tools_count'] = is_array( $tools ) ? count( $tools ) : 0; |
| 126 |
return $result; |
| 127 |
} |
| 128 |
|
| 129 |
$result['ok'] = true; |
| 130 |
$result['stage'] = 'ok'; |
| 131 |
$result['tools_count'] = count( $tools ); |
| 132 |
$result['message'] = sprintf( |
| 133 |
/* translators: %d: number of MCP tools returned. */ |
| 134 |
_n( 'Connection healthy: the endpoint authenticated and returned %d tool.', 'Connection healthy: the endpoint authenticated and returned %d tools.', $result['tools_count'], 'thinkrank' ), |
| 135 |
$result['tools_count'] |
| 136 |
); |
| 137 |
return $result; |
| 138 |
} |
| 139 |
} |
| 140 |
|