Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions includes/class-openclawp-mcp-client-transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@

final class OpenclaWP_Mcp_Client_Transport {

private const PROTOCOL_VERSION = '2025-06-18';
private const READ_TIMEOUT_SECONDS = 15;
private const PROTOCOL_VERSION = '2025-06-18';
private const READ_TIMEOUT_SECONDS = 15;
private const EXECUTION_LIMIT_BUFFER_SECONDS = 5;

/**
* Probe a configured server: initialize + tools/list. Returns the tool
Expand Down Expand Up @@ -159,7 +160,7 @@ private static function call_stdio( array $config, string $tool_name, array $arg

/**
* @param array<string,mixed> $config
* @return array{process:resource, pipes:array<int,resource>, id:int}|\WP_Error
* @return array{process:resource, pipes:array<int,resource>, id:int, deadline:?float}|\WP_Error
*/
private static function open_stdio_session( array $config ) {
$command = (string) ( $config['command'] ?? '' );
Expand Down Expand Up @@ -194,14 +195,15 @@ private static function open_stdio_session( array $config ) {
stream_set_blocking( $pipes[2], false );

return array(
'process' => $process,
'pipes' => $pipes,
'id' => 0,
'process' => $process,
'pipes' => $pipes,
'id' => 0,
'deadline' => self::request_execution_deadline(),
);
}

/**
* @param array{process:resource, pipes:array<int,resource>, id:int} $session
* @param array{process:resource, pipes:array<int,resource>, id:int, deadline:?float} $session
*/
private static function close_stdio_session( array $session ): void {
foreach ( $session['pipes'] as $pipe ) {
Expand All @@ -218,7 +220,7 @@ private static function close_stdio_session( array $session ): void {
/**
* Send a JSON-RPC request frame and block for the matching response.
*
* @param array{process:resource, pipes:array<int,resource>, id:int} $session
* @param array{process:resource, pipes:array<int,resource>, id:int, deadline:?float} $session
*
* @return mixed|\WP_Error
*/
Expand All @@ -237,8 +239,12 @@ private static function send_stdio( array &$session, string $method, $params ) {
return new \WP_Error( 'mcp_client_write_failed', sprintf( 'write to stdin failed for %s', $method ) );
}

$deadline = microtime( true ) + self::READ_TIMEOUT_SECONDS;
$buffer = '';
$started_at = microtime( true );
$deadline = $started_at + self::READ_TIMEOUT_SECONDS;
if ( isset( $session['deadline'] ) && is_float( $session['deadline'] ) ) {
$deadline = min( $deadline, $session['deadline'] );
}
$buffer = '';

while ( microtime( true ) < $deadline ) {
$chunk = @fgets( $session['pipes'][1] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
Expand All @@ -252,7 +258,8 @@ private static function send_stdio( array &$session, string $method, $params ) {
sprintf( 'process exited (code %d) before %s response: %s', (int) $status['exitcode'], $method, is_string( $stderr ) ? trim( $stderr ) : '' )
);
}
usleep( 50000 ); // 50ms
$remaining_usec = (int) max( 1000, min( 50000, ( $deadline - microtime( true ) ) * 1000000 ) );
usleep( $remaining_usec );
continue;
}

Expand Down Expand Up @@ -284,16 +291,17 @@ private static function send_stdio( array &$session, string $method, $params ) {
return $decoded['result'] ?? array();
}

$elapsed = max( 0.0, microtime( true ) - $started_at );
return new \WP_Error(
'mcp_client_timeout',
sprintf( 'timed out waiting for %s response after %ds', $method, self::READ_TIMEOUT_SECONDS )
sprintf( 'timed out waiting for %s response after %.1fs', $method, $elapsed )
);
}

/**
* Fire-and-forget JSON-RPC notification (no id, no response expected).
*
* @param array{process:resource, pipes:array<int,resource>, id:int} $session
* @param array{process:resource, pipes:array<int,resource>, id:int, deadline:?float} $session
*/
private static function notify_stdio( array $session, string $method, $params ): void {
$payload = wp_json_encode(
Expand All @@ -306,6 +314,28 @@ private static function notify_stdio( array $session, string $method, $params ):
@fwrite( $session['pipes'][0], $payload ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
}

/**
* Estimate a request-local deadline that leaves time for PHP to unwind.
*
* A stdio operation can make more than one JSON-RPC request in sequence
* (initialize, then tools/list or tools/call). Keeping only a per-read
* timeout risks colliding with the host's max_execution_time before the
* transport can return a WP_Error.
*
* @return float|null Unix timestamp with microseconds, or null when PHP has no execution limit.
*/
private static function request_execution_deadline(): ?float {
$limit = (int) ini_get( 'max_execution_time' );
if ( $limit <= 0 ) {
return null;
}

$started_at = isset( $_SERVER['REQUEST_TIME_FLOAT'] ) ? (float) $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
$deadline = $started_at + max( 1, $limit - self::EXECUTION_LIMIT_BUFFER_SECONDS );

return max( microtime( true ) + 1, $deadline );
}

// -------------------------------------------------------------------
// http
// -------------------------------------------------------------------
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/McpClientTransportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
/**
* Unit tests for OpenclaWP_Mcp_Client_Transport.
*
* @package OpenclaWP\Tests
*/

declare( strict_types=1 );

namespace OpenclaWP\Tests\Unit;

use OpenclaWP_Mcp_Client_Transport;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;

/**
* @covers OpenclaWP_Mcp_Client_Transport
*/
final class McpClientTransportTest extends TestCase {

private string $previous_max_execution_time = '0';
private $previous_request_time_float = null;
private bool $had_request_time_float = false;

protected function setUp(): void {
parent::setUp();
$this->previous_max_execution_time = (string) ini_get( 'max_execution_time' );
$this->had_request_time_float = array_key_exists( 'REQUEST_TIME_FLOAT', $_SERVER );
$this->previous_request_time_float = $_SERVER['REQUEST_TIME_FLOAT'] ?? null;
}

protected function tearDown(): void {
ini_set( 'max_execution_time', $this->previous_max_execution_time );
if ( $this->had_request_time_float ) {
$_SERVER['REQUEST_TIME_FLOAT'] = $this->previous_request_time_float;
} else {
unset( $_SERVER['REQUEST_TIME_FLOAT'] );
}
parent::tearDown();
}

public function test_request_execution_deadline_is_null_without_php_execution_limit(): void {
ini_set( 'max_execution_time', '0' );

$this->assertNull( self::request_execution_deadline() );
}

public function test_request_execution_deadline_leaves_margin_before_php_limit(): void {
$now = microtime( true );
$_SERVER['REQUEST_TIME_FLOAT'] = $now - 10;
ini_set( 'max_execution_time', '30' );

$deadline = self::request_execution_deadline();

$this->assertIsFloat( $deadline );
$this->assertGreaterThan( $now + 14, $deadline );
$this->assertLessThan( $now + 16, $deadline );
}

private static function request_execution_deadline(): ?float {
$method = new ReflectionMethod( OpenclaWP_Mcp_Client_Transport::class, 'request_execution_deadline' );
$method->setAccessible( true );

return $method->invoke( null );
}
}
Loading