From 50595e45b985c396196219b0ac09b7b0ee7cd4ce Mon Sep 17 00:00:00 2001 From: Sarthak Jaiswal Date: Mon, 24 Aug 2026 18:37:44 +0530 Subject: [PATCH] feat(transport): implement SSE streaming for the HTTP GET endpoint The HTTP transport's GET endpoint returned 405 unconditionally, so the only documented way to reach a WordPress MCP server was some form of STDIO on the client side (wp mcp-adapter serve locally, or the mcp-wordpress-remote proxy forwarding to HTTP). Clients that run in a hosted sandbox and can't launch local processes had no way to connect. GET now opens a real Server-Sent Events stream per the MCP Streamable HTTP transport, after the same session and protocol-version validation POST already performs. The stream is held open for a bounded, filterable duration (30s default) with periodic keep-alive pings, then closes; compliant clients reconnect automatically, so a slow or idle connection never ties up a PHP-FPM worker indefinitely. Streaming happens outside the normal WP_REST_Server JSON envelope via a scoped rest_pre_serve_request filter, so calling the handler directly (as tests do) never blocks. Clients that support remote/custom MCP connectors can now point straight at the site's existing REST endpoint over HTTPS with an Application Password, with no local proxy required. --- CHANGELOG.md | 3 + docs/guides/cli-usage.md | 34 ++++ docs/guides/default-server.md | 2 +- includes/Transport/HttpTransport.php | 14 +- .../Infrastructure/HttpRequestHandler.php | 76 +++++++- .../Transport/Infrastructure/SseStream.php | 170 ++++++++++++++++++ .../phpunit/Integration/HttpTransportTest.php | 64 ++++++- .../Infrastructure/HttpRequestHandlerTest.php | 82 ++++++++- .../Infrastructure/SseStreamTest.php | 103 +++++++++++ 9 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 includes/Transport/Infrastructure/SseStream.php create mode 100644 tests/phpunit/Unit/Transport/Infrastructure/SseStreamTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ff88af5f..20d4b99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file, per [the Ke ## [Unreleased] - TBD +### Added +- The HTTP transport's `GET` endpoint now opens a real SSE stream (per the MCP Streamable HTTP transport) instead of returning `405`, so clients that can only reach a WordPress site over HTTPS — including remote/cloud MCP clients that cannot spawn a local STDIO process — can connect directly using an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/), with no local proxy required. The stream is held open for a bounded duration (30 seconds by default) before closing so a single connection can't tie up a PHP-FPM worker indefinitely; compliant clients reconnect automatically. New filters: `mcp_adapter_enable_http_sse_stream`, `mcp_adapter_sse_stream_duration`, `mcp_adapter_sse_ping_interval`. See the [CLI Usage guide](docs/guides/cli-usage.md#connecting-directly-over-http-remote-clients). + ## [0.6.1] - 2026-08-13 ### Fixed diff --git a/docs/guides/cli-usage.md b/docs/guides/cli-usage.md index b92b2f64..ba3b8499 100644 --- a/docs/guides/cli-usage.md +++ b/docs/guides/cli-usage.md @@ -146,6 +146,40 @@ The [`@automattic/mcp-wordpress-remote`](https://www.npmjs.com/package/@automatt For more information, see the [@automattic/mcp-wordpress-remote](https://www.npmjs.com/package/@automattic/mcp-wordpress-remote) package documentation. +#### Connecting directly over HTTP (remote clients) + +Both options above are STDIO from the client's point of view: `wp mcp-adapter serve` runs locally, and so does the `@automattic/mcp-wordpress-remote` proxy — it just forwards to HTTP behind the scenes. Neither works for a client that runs somewhere it can't launch local processes, such as Claude in Chat or Cowork mode, which runs in a hosted sandbox. + +For those clients, point them at the site's MCP REST endpoint directly — no local process required: + +``` +https://your-site.example/wp-json/mcp/mcp-adapter-default-server +``` + +Authenticate with a [WordPress Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) sent as HTTP Basic Auth, the same credential used by the proxy above. Whether a given client's remote-connector UI lets you supply Basic Auth credentials (as opposed to only OAuth) depends on the client; check its documentation for adding a custom/remote MCP connector. + +The endpoint implements the MCP Streamable HTTP transport: `POST` for JSON-RPC requests, `GET` for an SSE stream (used for server-initiated messages on an established session), and `DELETE` to end a session. For example, with `curl`: + +```bash +# Initialize a session (also returns an Mcp-Session-Id response header) +curl -u your-username:your-application-password \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"curl","version":"1.0.0"}}}' \ + https://your-site.example/wp-json/mcp/mcp-adapter-default-server + +# Open the SSE stream for that session +curl -N -u your-username:your-application-password \ + -H "Accept: text/event-stream" \ + -H "Mcp-Session-Id: " \ + https://your-site.example/wp-json/mcp/mcp-adapter-default-server +``` + +The SSE stream is held open only for a bounded duration (30 seconds by default) and then closes; compliant clients reconnect automatically. This keeps one slow or idle client from tying up a PHP-FPM worker indefinitely. Site owners can adjust this behavior with filters: + +- `mcp_adapter_enable_http_sse_stream` — return `false` to disable the SSE stream and make `GET` respond with `405` again (the transport still works over POST/DELETE without it). +- `mcp_adapter_sse_stream_duration` — how long, in seconds, a single stream stays open before closing (default `30`). +- `mcp_adapter_sse_ping_interval` — how often, in seconds, a keep-alive comment is sent while the stream is open (default `15`). + ### Development Workflow The CLI commands are particularly useful for development: diff --git a/docs/guides/default-server.md b/docs/guides/default-server.md index 75298547..b31ba094 100644 --- a/docs/guides/default-server.md +++ b/docs/guides/default-server.md @@ -109,7 +109,7 @@ Every HTTP client must complete an initialization handshake before sending any o 1. **Initialize** — Send a `POST` request with the `initialize` JSON-RPC method. No `Mcp-Session-Id` header is needed for this first request. 2. **Capture the session ID** — The response includes an `Mcp-Session-Id` header containing a UUID. Store this value. -3. **Include the header on every subsequent request** — All following `POST` and `DELETE` requests must include the `Mcp-Session-Id` header with the stored value. (The MCP specification also requires the header on `GET` requests for SSE streaming, but SSE is not yet implemented — `GET` currently returns `405 Method Not Allowed`.) +3. **Include the header on every subsequent request** — All following `POST`, `GET`, and `DELETE` requests must include the `Mcp-Session-Id` header with the stored value. `GET` opens an SSE stream for server-initiated messages on that session; see [CLI Usage](cli-usage.md#connecting-directly-over-http-remote-clients) for details and the filters that control it. 4. **Terminate when done** — Send a `DELETE` request with the `Mcp-Session-Id` header to clean up the session. ### Curl example diff --git a/includes/Transport/HttpTransport.php b/includes/Transport/HttpTransport.php index 4899f61c..efa33a2a 100644 --- a/includes/Transport/HttpTransport.php +++ b/includes/Transport/HttpTransport.php @@ -3,9 +3,8 @@ * MCP HTTP Transport for WordPress (MCP 2025-11-25 baseline) * * This transport implements the MCP HTTP transport surface used by this plugin. - * It can work both with and without the mcp-wordpress-remote proxy. - * - * Note: SSE (GET streaming) is not yet implemented; GET currently returns 405. + * It can work both with and without the mcp-wordpress-remote proxy, and + * clients that support remote HTTP MCP servers can connect to it directly. * * @package McpAdapter */ @@ -26,9 +25,9 @@ /** * MCP HTTP Transport - Unified transport for both proxy and direct clients * - * Implements the MCP 2025-11-25 HTTP transport shape used by this adapter (POST + sessions). - * - * Note: SSE (GET streaming) is not yet implemented; GET currently returns 405. + * Implements the MCP 2025-11-25 HTTP transport shape used by this adapter: + * POST for JSON-RPC messages, GET for an SSE stream (see {@see \WP\MCP\Transport\Infrastructure\SseStream}), + * and DELETE for session termination, all backed by sessions. */ class HttpTransport implements McpRestTransportInterface { use McpTransportHelperTrait; @@ -57,8 +56,7 @@ public function register_routes(): void { // Get server info from request handler's transport context $server = $this->request_handler->get_transport_context()->mcp_server; - // Single endpoint for MCP communication (POST, GET reserved for SSE, DELETE for session termination). - // Do not remove GET: it is part of the MCP HTTP transport shape and will be implemented (SSE) in a future iteration. + // Single endpoint for MCP communication (POST for JSON-RPC, GET for the SSE stream, DELETE for session termination). register_rest_route( $server->get_server_route_namespace(), $server->get_server_route(), diff --git a/includes/Transport/Infrastructure/HttpRequestHandler.php b/includes/Transport/Infrastructure/HttpRequestHandler.php index 9914225a..2102952e 100644 --- a/includes/Transport/Infrastructure/HttpRequestHandler.php +++ b/includes/Transport/Infrastructure/HttpRequestHandler.php @@ -65,9 +65,9 @@ public function handle_request( HttpRequestContext $context ): \WP_REST_Response return $this->handle_mcp_request( $context ); } - // Handle GET requests (reserved for SSE streaming; currently not implemented). + // Handle GET requests (SSE streaming). if ( 'GET' === $context->method ) { - return $this->handle_sse_request(); + return $this->handle_sse_request( $context ); } // Handle DELETE requests (session termination) @@ -310,11 +310,77 @@ static function ( $response ) use ( $session_id ) { /** * Handle GET requests (SSE streaming). * + * Validates the session and protocol version exactly like a POST request + * would, then hands the request off to {@see SseStream} for the actual + * streaming. Streaming only happens when this response is served through + * the normal WordPress REST dispatch (via the `rest_pre_serve_request` + * filter registered below) — calling this method directly, as tests do, + * never blocks. + * + * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. + * * @return \WP_REST_Response SSE response. */ - private function handle_sse_request(): \WP_REST_Response { - // SSE streaming not yet implemented - return HTTP 405 with no body - return new \WP_REST_Response( null, 405 ); + private function handle_sse_request( HttpRequestContext $context ): \WP_REST_Response { + /** + * Filters whether the SSE (GET) stream of the MCP HTTP transport is enabled. + * + * Return false to keep GET requests responding with HTTP 405, e.g. on + * hosting environments where holding a request open is undesirable. + * The MCP Streamable HTTP specification allows a server to omit SSE + * support entirely, so disabling this does not break the transport. + * + * @since 0.7.0 + * + * @param bool $enabled Whether the SSE stream is enabled. Default true. + */ + if ( ! apply_filters( 'mcp_adapter_enable_http_sse_stream', true ) ) { + return new \WP_REST_Response( null, 405 ); + } + + $session_validation = HttpSessionValidator::validate_session_with_error_handler( $context, $this->transport_context->error_handler ); + if ( true !== $session_validation ) { + return new \WP_REST_Response( $session_validation, McpErrorFactory::get_http_status_for_error( $session_validation ) ); + } + + $protocol_version_error = $this->validate_protocol_version_header( $context ); + if ( null !== $protocol_version_error ) { + $response_body = JsonRpcResponseBuilder::create_error_response( null, $protocol_version_error ); + + return new \WP_REST_Response( $response_body, McpErrorFactory::get_http_status_for_error( $response_body ) ); + } + + $this->register_sse_stream( $context->request ); + + return new \WP_REST_Response( null, 200 ); + } + + /** + * Register the raw SSE stream to run when this request is actually served. + * + * WordPress's REST server JSON-encodes whatever a route callback returns. + * To send a raw `text/event-stream` body instead, this hooks + * `rest_pre_serve_request` — the point at which WP_REST_Server would + * otherwise encode and echo the response — and takes over output for the + * matching request only. + * + * @param \WP_REST_Request> $request The originating GET request. + * + * @return void + */ + private function register_sse_stream( \WP_REST_Request $request ): void { + $callback = static function ( $served, $result, $served_request ) use ( $request, &$callback ) { + if ( $served_request !== $request ) { + return $served; + } + + remove_filter( 'rest_pre_serve_request', $callback ); + ( new SseStream() )->stream(); + + return true; + }; + + add_filter( 'rest_pre_serve_request', $callback, 10, 3 ); } /** diff --git a/includes/Transport/Infrastructure/SseStream.php b/includes/Transport/Infrastructure/SseStream.php new file mode 100644 index 00000000..3eb8eb9c --- /dev/null +++ b/includes/Transport/Infrastructure/SseStream.php @@ -0,0 +1,170 @@ +prepare_environment(); + $this->send_headers(); + + echo self::format_comment( 'stream-open' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw SSE frame, not HTML. + self::flush_output(); + + $duration = self::get_stream_duration(); + $interval = self::get_ping_interval(); + $start = microtime( true ); + $next_tick = $start + $interval; + + while ( microtime( true ) - $start < $duration ) { + if ( connection_aborted() ) { + break; + } + + if ( microtime( true ) >= $next_tick ) { + echo self::format_comment( 'ping' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw SSE frame, not HTML. + self::flush_output(); + $next_tick = microtime( true ) + $interval; + } + + usleep( 200000 ); + } + } + + /** + * Best-effort environment preparation so the stream can run past normal request limits. + * + * @return void + */ + private function prepare_environment(): void { + // Some hosts disable set_time_limit() or restrict ini_set(), which + // raises a warning rather than simply failing. Either call is purely + // best-effort here, and a warning would otherwise land in the SSE + // body and corrupt the stream, so failures are swallowed explicitly + // instead of relying on the `@` operator. + set_error_handler( '__return_true' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Not debug code; scoped below to swallow a possible host-restriction warning that would otherwise corrupt the SSE body. + + try { + if ( function_exists( 'set_time_limit' ) ) { + set_time_limit( 0 ); + } + + ini_set( 'zlib.output_compression', '0' ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- Best-effort; compression buffering would defeat streaming. + } finally { + restore_error_handler(); + } + } + + /** + * Send the SSE response headers. + * + * @return void + */ + private function send_headers(): void { + if ( headers_sent() ) { + return; + } + + header( 'Content-Type: text/event-stream; charset=utf-8' ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + header( 'Connection: keep-alive' ); + // Prevents common reverse proxies (e.g. nginx) from buffering the stream. + header( 'X-Accel-Buffering: no' ); + } + + /** + * Flush the current output as far as PHP and the SAPI allow. + * + * @return void + */ + private static function flush_output(): void { + if ( ob_get_level() > 0 ) { + ob_flush(); + } + + flush(); + } + + /** + * Format an SSE comment line. + * + * Comment lines (leading colon) are ignored by EventSource's message + * parsing, so they are safe to use purely to keep the connection alive. + * + * @param string $text The comment text. + * + * @return string The formatted SSE frame. + */ + public static function format_comment( string $text ): string { + return ': ' . $text . "\n\n"; + } + + /** + * Get the configured total stream duration in seconds. + * + * @return int Stream duration in seconds. + */ + public static function get_stream_duration(): int { + /** + * Filters how long the MCP HTTP transport holds an SSE (GET) stream open. + * + * The stream is closed after this many seconds regardless of activity. + * Compliant EventSource clients reconnect automatically, so lowering + * this only changes how often a reconnect happens, not whether the + * feature works. + * + * @since 0.7.0 + * + * @param int $duration Stream duration in seconds. Default 30. + */ + $duration = (int) apply_filters( 'mcp_adapter_sse_stream_duration', 30 ); + + return max( 0, $duration ); + } + + /** + * Get the configured keep-alive ping interval in seconds. + * + * @return int Ping interval in seconds (at least 1). + */ + public static function get_ping_interval(): int { + /** + * Filters how often the MCP HTTP transport sends an SSE keep-alive comment. + * + * @since 0.7.0 + * + * @param int $interval Ping interval in seconds. Default 15. + */ + $interval = (int) apply_filters( 'mcp_adapter_sse_ping_interval', 15 ); + + return max( 1, $interval ); + } +} diff --git a/tests/phpunit/Integration/HttpTransportTest.php b/tests/phpunit/Integration/HttpTransportTest.php index 208b01cc..547bd6d0 100644 --- a/tests/phpunit/Integration/HttpTransportTest.php +++ b/tests/phpunit/Integration/HttpTransportTest.php @@ -30,7 +30,8 @@ * * Tests cover: * - POST requests with JSON-RPC messages - * - GET requests for SSE streaming (currently returns 405) + * - GET requests for SSE streaming (session/protocol validation; the actual + * stream is only exercised via WP_REST_Server dispatch, not unit tests) * - DELETE requests for session termination * - OPTIONS requests for CORS preflight * - Session management @@ -323,14 +324,71 @@ public function test_post_request_initialize_unauthenticated_returns_proper_json // ========== GET Request Tests ========== - public function test_get_request_for_sse_stream(): void { + public function test_get_request_for_sse_stream_without_session_id(): void { $request = new WP_REST_Request( 'GET', '/test-mcp' ); $request->set_header( 'Accept', 'text/event-stream' ); $response = $this->transport->handle_request( $request ); $this->assertInstanceOf( WP_REST_Response::class, $response ); - // SSE not implemented returns 405 with no body per HTTP standards + $this->assertEquals( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Missing Mcp-Session-Id header', $data['error']['message'] ); + } + + public function test_get_request_for_sse_stream_with_valid_session(): void { + // First create a session + $init_request = $this->createPostRequest( + array( + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => array( + 'protocolVersion' => '2025-11-25', + 'clientInfo' => array( + 'name' => 'test-client', + 'version' => '1.0.0', + ), + ), + ) + ); + $init_response = $this->transport->handle_request( $init_request ); + $this->assertArrayHasKey( 'result', $init_response->get_data(), 'Initialize must succeed' ); + + // The session header is set via a rest_post_dispatch filter which doesn't + // fire when calling handle_request() directly, so read the session ID + // straight from where it was persisted instead. + $sessions = get_user_meta( get_current_user_id(), self::session_meta_key(), true ); + $this->assertNotEmpty( $sessions, 'Initialize must create a session in user meta' ); + $session_id = (string) array_key_last( $sessions ); + + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + $request->set_header( 'Mcp-Session-Id', $session_id ); + + // Calling handle_request() directly never triggers the actual stream + // (that only happens via the `rest_pre_serve_request` filter during a + // real WP_REST_Server dispatch), so this assertion completes immediately. + $response = $this->transport->handle_request( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertNull( $response->get_data() ); + } + + public function test_get_request_for_sse_stream_can_be_disabled_via_filter(): void { + add_filter( 'mcp_adapter_enable_http_sse_stream', '__return_false' ); + + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + + $response = $this->transport->handle_request( $request ); + + remove_filter( 'mcp_adapter_enable_http_sse_stream', '__return_false' ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); $this->assertEquals( 405, $response->get_status() ); $this->assertNull( $response->get_data() ); } diff --git a/tests/phpunit/Unit/Transport/Infrastructure/HttpRequestHandlerTest.php b/tests/phpunit/Unit/Transport/Infrastructure/HttpRequestHandlerTest.php index d85a7806..feb63fc9 100644 --- a/tests/phpunit/Unit/Transport/Infrastructure/HttpRequestHandlerTest.php +++ b/tests/phpunit/Unit/Transport/Infrastructure/HttpRequestHandlerTest.php @@ -292,7 +292,7 @@ public function test_handle_request_post_notification(): void { $this->assertNull( $response->get_data() ); } - public function test_handle_request_get_sse(): void { + public function test_handle_request_get_sse_without_session_id(): void { $request = new WP_REST_Request( 'GET', '/test-mcp' ); $request->set_header( 'Accept', 'text/event-stream' ); @@ -300,9 +300,87 @@ public function test_handle_request_get_sse(): void { $response = $this->handler->handle_request( $context ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Missing Mcp-Session-Id header', $data['error']['message'] ); + } + + public function test_handle_request_get_sse_with_invalid_session_id(): void { + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + $request->set_header( 'Mcp-Session-Id', 'invalid-session' ); + + $context = new HttpRequestContext( $request ); + + $response = $this->handler->handle_request( $context ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Invalid or expired session', $data['error']['message'] ); + } + + public function test_handle_request_get_sse_with_valid_session_returns_immediately(): void { + $session_id = $this->initializeAndGetSessionId(); + + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + $request->set_header( 'Mcp-Session-Id', $session_id ); + + $context = new HttpRequestContext( $request ); + + // Calling the handler directly (as opposed to going through + // WP_REST_Server::serve_request()) must never block: the actual + // stream only runs from the `rest_pre_serve_request` filter. + $response = $this->handler->handle_request( $context ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertNull( $response->get_data() ); + } + + public function test_handle_request_get_sse_with_unsupported_protocol_version_returns_error(): void { + $session_id = $this->initializeAndGetSessionId(); + + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + $request->set_header( 'Mcp-Session-Id', $session_id ); + $request->set_header( 'Mcp-Protocol-Version', '9999-99-99' ); + + $context = new HttpRequestContext( $request ); + + $response = $this->handler->handle_request( $context ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertEquals( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Unsupported protocol version', $data['error']['message'] ); + } + + public function test_handle_request_get_sse_disabled_via_filter_returns_405(): void { + $session_id = $this->initializeAndGetSessionId(); + + add_filter( 'mcp_adapter_enable_http_sse_stream', '__return_false' ); + + $request = new WP_REST_Request( 'GET', '/test-mcp' ); + $request->set_header( 'Accept', 'text/event-stream' ); + $request->set_header( 'Mcp-Session-Id', $session_id ); + + $context = new HttpRequestContext( $request ); + + $response = $this->handler->handle_request( $context ); + + remove_filter( 'mcp_adapter_enable_http_sse_stream', '__return_false' ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); $this->assertEquals( 405, $response->get_status() ); - // SSE not implemented returns 405 with no body per HTTP standards $this->assertNull( $response->get_data() ); } diff --git a/tests/phpunit/Unit/Transport/Infrastructure/SseStreamTest.php b/tests/phpunit/Unit/Transport/Infrastructure/SseStreamTest.php new file mode 100644 index 00000000..bcc36fd0 --- /dev/null +++ b/tests/phpunit/Unit/Transport/Infrastructure/SseStreamTest.php @@ -0,0 +1,103 @@ +assertSame( ": ping\n\n", $frame ); + } + + public function test_get_stream_duration_defaults_to_thirty_seconds(): void { + $this->assertSame( 30, SseStream::get_stream_duration() ); + } + + public function test_get_stream_duration_respects_filter(): void { + add_filter( + 'mcp_adapter_sse_stream_duration', + static function () { + return 5; + } + ); + + $this->assertSame( 5, SseStream::get_stream_duration() ); + } + + public function test_get_stream_duration_clamps_negative_values_to_zero(): void { + add_filter( + 'mcp_adapter_sse_stream_duration', + static function () { + return -10; + } + ); + + $this->assertSame( 0, SseStream::get_stream_duration() ); + } + + public function test_get_ping_interval_defaults_to_fifteen_seconds(): void { + $this->assertSame( 15, SseStream::get_ping_interval() ); + } + + public function test_get_ping_interval_clamps_to_at_least_one_second(): void { + add_filter( + 'mcp_adapter_sse_ping_interval', + static function () { + return 0; + } + ); + + $this->assertSame( 1, SseStream::get_ping_interval() ); + } + + public function test_stream_with_zero_duration_sends_only_the_open_comment(): void { + // With a zero-second duration, stream() must open (and send) the + // stream and return immediately instead of entering the keep-alive + // loop. stream() calls the real flush(), which bypasses ordinary + // output buffering, so a callback-based buffer is used to both + // capture and swallow the bytes instead of letting them reach the + // terminal. + add_filter( + 'mcp_adapter_sse_stream_duration', + static function () { + return 0; + } + ); + + $captured = ''; + ob_start( + static function ( string $buffer ) use ( &$captured ): string { + $captured .= $buffer; + + return ''; + } + ); + + ( new SseStream() )->stream(); + + ob_end_clean(); + + $this->assertSame( SseStream::format_comment( 'stream-open' ), $captured ); + } +}