From db860a3c424b04dde40443804b3cd6dcff1399e6 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 3 Jun 2026 12:42:56 -0300 Subject: [PATCH] Cap MCP stdio session timeouts --- .../class-openclawp-mcp-client-transport.php | 56 ++++++++++++---- tests/unit/McpClientTransportTest.php | 66 +++++++++++++++++++ 2 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 tests/unit/McpClientTransportTest.php diff --git a/includes/class-openclawp-mcp-client-transport.php b/includes/class-openclawp-mcp-client-transport.php index dc35cea..3c19d9c 100644 --- a/includes/class-openclawp-mcp-client-transport.php +++ b/includes/class-openclawp-mcp-client-transport.php @@ -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 @@ -159,7 +160,7 @@ private static function call_stdio( array $config, string $tool_name, array $arg /** * @param array $config - * @return array{process:resource, pipes:array, id:int}|\WP_Error + * @return array{process:resource, pipes:array, id:int, deadline:?float}|\WP_Error */ private static function open_stdio_session( array $config ) { $command = (string) ( $config['command'] ?? '' ); @@ -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, id:int} $session + * @param array{process:resource, pipes:array, id:int, deadline:?float} $session */ private static function close_stdio_session( array $session ): void { foreach ( $session['pipes'] as $pipe ) { @@ -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, id:int} $session + * @param array{process:resource, pipes:array, id:int, deadline:?float} $session * * @return mixed|\WP_Error */ @@ -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 @@ -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; } @@ -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, id:int} $session + * @param array{process:resource, pipes:array, id:int, deadline:?float} $session */ private static function notify_stdio( array $session, string $method, $params ): void { $payload = wp_json_encode( @@ -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 // ------------------------------------------------------------------- diff --git a/tests/unit/McpClientTransportTest.php b/tests/unit/McpClientTransportTest.php new file mode 100644 index 0000000..c8edaf4 --- /dev/null +++ b/tests/unit/McpClientTransportTest.php @@ -0,0 +1,66 @@ +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 ); + } +}