Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions docs/guides/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <id from the response above>" \
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:
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/default-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 6 additions & 8 deletions includes/Transport/HttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
76 changes: 71 additions & 5 deletions includes/Transport/Infrastructure/HttpRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<array<string, mixed>> $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 );
}

/**
Expand Down
170 changes: 170 additions & 0 deletions includes/Transport/Infrastructure/SseStream.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php
/**
* SSE Stream for the MCP HTTP Transport GET endpoint
*
* @package McpAdapter
*/

declare( strict_types=1 );

namespace WP\MCP\Transport\Infrastructure;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
* Streams a Server-Sent Events response for the Streamable HTTP GET endpoint.
*
* WordPress requests run on synchronous PHP-FPM/mod_php workers rather than a
* long-running event loop, so a stream is only held open for a bounded
* duration and then closed. Compliant EventSource clients reconnect
* automatically, which is what keeps a single connection from tying up a
* worker indefinitely.
*/
class SseStream {

/**
* Stream keep-alive pings for a validated GET request.
*
* Sends the SSE headers, an initial comment so the client knows the
* stream opened successfully, then periodic keep-alive comments until
* the configured duration elapses or the client disconnects.
*
* @return void
*/
public function stream(): void {
$this->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 );

Check warning on line 77 in includes/Transport/Infrastructure/SseStream.php

View workflow job for this annotation

GitHub Actions / Run Plugin Check

Squiz.PHP.DiscouragedFunctions.Discouraged

The use of function set_time_limit() is discouraged
}

ini_set( 'zlib.output_compression', '0' ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- Best-effort; compression buffering would defeat streaming.

Check warning on line 80 in includes/Transport/Infrastructure/SseStream.php

View workflow job for this annotation

GitHub Actions / Run Plugin Check

Squiz.PHP.DiscouragedFunctions.Discouraged

The use of function ini_set() is discouraged
} 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 );
}
}
Loading
Loading