Skip to content

feat: Add PSR-7 middleware support for HTTP transports #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 9, 2025
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,74 @@ $server = Server::make()
->build();
```

### Middleware Support

Both `HttpServerTransport` and `StreamableHttpServerTransport` support PSR-7 compatible middleware for intercepting and modifying HTTP requests and responses. Middleware allows you to extract common functionality like authentication, logging, CORS handling, and request validation into reusable components.

Middleware must be a valid PHP callable that accepts a PSR-7 `ServerRequestInterface` as the first argument and a `callable` as the second argument.

```php
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Promise\PromiseInterface;

class AuthMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$apiKey = $request->getHeaderLine('Authorization');
if (empty($apiKey)) {
return new Response(401, [], 'Authorization required');
}

$request = $request->withAttribute('user_id', $this->validateApiKey($apiKey));
$result = $next($request);

return match (true) {
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
$result instanceof ResponseInterface => $this->handle($result),
default => $result
};
}

private function handle($response)
{
return $response instanceof ResponseInterface
? $response->withHeader('X-Auth-Provider', 'mcp-server')
: $response;
}
}

$middlewares = [
new AuthMiddleware(),
new LoggingMiddleware(),
function(ServerRequestInterface $request, callable $next) {
$result = $next($request);
return match (true) {
$result instanceof PromiseInterface => $result->then(function($response) {
return $response instanceof ResponseInterface
? $response->withHeader('Access-Control-Allow-Origin', '*')
: $response;
}),
$result instanceof ResponseInterface => $result->withHeader('Access-Control-Allow-Origin', '*'),
default => $result
};
}
];

$transport = new StreamableHttpServerTransport(
host: '127.0.0.1',
port: 8080,
middlewares: $middlewares
);
```

**Important Considerations:**

- **Response Handling**: Middleware must handle both synchronous `ResponseInterface` and asynchronous `PromiseInterface` returns from `$next($request)`, since ReactPHP operates asynchronously
- **Invokable Pattern**: The recommended pattern is to use invokable classes with a separate `handle()` method to process responses, making the async logic reusable
- **Execution Order**: Middleware executes in the order provided, with the last middleware being closest to your MCP handlers

### SSL Context Configuration

For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options:
Expand Down
16 changes: 14 additions & 2 deletions src/Transports/HttpServerTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,25 @@ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterf
* @param int $port Port to listen on (e.g., 8080).
* @param string $mcpPathPrefix URL prefix for MCP endpoints (e.g., 'mcp').
* @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS).
* @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
*/
public function __construct(
private readonly string $host = '127.0.0.1',
private readonly int $port = 8080,
private readonly string $mcpPathPrefix = 'mcp',
private readonly ?array $sslContext = null,
private array $middlewares = []
) {
$this->logger = new NullLogger();
$this->loop = Loop::get();
$this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse';
$this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message';

foreach ($this->middlewares as $mw) {
if (!is_callable($mw)) {
throw new \InvalidArgumentException('All provided middlewares must be callable.');
}
}
}

public function setLogger(LoggerInterface $logger): void
Expand Down Expand Up @@ -114,7 +122,8 @@ public function listen(): void
$this->loop
);

$this->http = new HttpServer($this->loop, $this->createRequestHandler());
$handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
$this->http = new HttpServer($this->loop, ...$handlers);
$this->http->listen($this->socket);

$this->socket->on('error', function (Throwable $error) {
Expand Down Expand Up @@ -261,7 +270,10 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re
return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
}

$this->emit('message', [$message, $sessionId]);
$context = [
'request' => $request,
];
$this->emit('message', [$message, $sessionId, $context]);

return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted');
}
Expand Down
15 changes: 13 additions & 2 deletions src/Transports/StreamableHttpServerTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA

/**
* @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream.
* @param bool $stateless If true, the server will not emit client_connected events.
* @param EventStoreInterface $eventStore If provided, the server will replay events to the client.
* @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
* This can be useful for simple request/response scenarios without streaming.
*/
public function __construct(
Expand All @@ -76,12 +79,19 @@ public function __construct(
private ?array $sslContext = null,
private readonly bool $enableJsonResponse = true,
private readonly bool $stateless = false,
?EventStoreInterface $eventStore = null
?EventStoreInterface $eventStore = null,
private array $middlewares = []
) {
$this->logger = new NullLogger();
$this->loop = Loop::get();
$this->mcpPath = '/' . trim($mcpPath, '/');
$this->eventStore = $eventStore;

foreach ($this->middlewares as $mw) {
if (!is_callable($mw)) {
throw new \InvalidArgumentException('All provided middlewares must be callable.');
}
}
}

protected function generateId(): string
Expand Down Expand Up @@ -119,7 +129,8 @@ public function listen(): void
$this->loop
);

$this->http = new HttpServer($this->loop, $this->createRequestHandler());
$handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
$this->http = new HttpServer($this->loop, ...$handlers);
$this->http->listen($this->socket);

$this->socket->on('error', function (Throwable $error) {
Expand Down
21 changes: 21 additions & 0 deletions tests/Fixtures/General/RequestAttributeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\General;

use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Server\Context;

class RequestAttributeChecker
{
public function checkAttribute(Context $context): TextContent
{
$attribute = $context->request->getAttribute('middleware-attr');
if ($attribute === 'middleware-value') {
return TextContent::make('middleware-value-found: ' . $attribute);
}

return TextContent::make('middleware-value-not-found: ' . $attribute);
}
}
18 changes: 18 additions & 0 deletions tests/Fixtures/Middlewares/ErrorMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;

class ErrorMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
if (str_contains($request->getUri()->getPath(), '/error-middleware')) {
throw new \Exception('Middleware error');
}
return $next($request);
}
}
33 changes: 33 additions & 0 deletions tests/Fixtures/Middlewares/FirstMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Promise\PromiseInterface;

class FirstMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$result = $next($request);

return match (true) {
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
$result instanceof ResponseInterface => $this->handle($result),
default => $result
};
}

private function handle($response)
{
if ($response instanceof ResponseInterface) {
$existing = $response->getHeaderLine('X-Middleware-Order');
$new = $existing ? $existing . ',first' : 'first';
return $response->withHeader('X-Middleware-Order', $new);
}
return $response;
}
}
30 changes: 30 additions & 0 deletions tests/Fixtures/Middlewares/HeaderMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Promise\PromiseInterface;

class HeaderMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$result = $next($request);

return match (true) {
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
$result instanceof ResponseInterface => $this->handle($result),
default => $result
};
}

private function handle($response)
{
return $response instanceof ResponseInterface
? $response->withHeader('X-Test-Middleware', 'header-added')
: $response;
}
}
16 changes: 16 additions & 0 deletions tests/Fixtures/Middlewares/RequestAttributeMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;

class RequestAttributeMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$request = $request->withAttribute('middleware-attr', 'middleware-value');
return $next($request);
}
}
33 changes: 33 additions & 0 deletions tests/Fixtures/Middlewares/SecondMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Promise\PromiseInterface;

class SecondMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$result = $next($request);

return match (true) {
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
$result instanceof ResponseInterface => $this->handle($result),
default => $result
};
}

private function handle($response)
{
if ($response instanceof ResponseInterface) {
$existing = $response->getHeaderLine('X-Middleware-Order');
$new = $existing ? $existing . ',second' : 'second';
return $response->withHeader('X-Middleware-Order', $new);
}
return $response;
}
}
19 changes: 19 additions & 0 deletions tests/Fixtures/Middlewares/ShortCircuitMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;

class ShortCircuitMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
if (str_contains($request->getUri()->getPath(), '/short-circuit')) {
return new Response(418, [], 'Short-circuited by middleware');
}
return $next($request);
}
}
33 changes: 33 additions & 0 deletions tests/Fixtures/Middlewares/ThirdMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Tests\Fixtures\Middlewares;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Promise\PromiseInterface;

class ThirdMiddleware
{
public function __invoke(ServerRequestInterface $request, callable $next)
{
$result = $next($request);

return match (true) {
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
$result instanceof ResponseInterface => $this->handle($result),
default => $result
};
}

private function handle($response)
{
if ($response instanceof ResponseInterface) {
$existing = $response->getHeaderLine('X-Middleware-Order');
$new = $existing ? $existing . ',third' : 'third';
return $response->withHeader('X-Middleware-Order', $new);
}
return $response;
}
}
Loading