Skip to content

Commit 5de0cb8

Browse files
feat: add middleware support to HttpServerTransport and StreamableHttpServerTransport
1 parent 4b34a02 commit 5de0cb8

17 files changed

+547
-10
lines changed

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,74 @@ $server = Server::make()
10731073
->build();
10741074
```
10751075

1076+
### Middleware Support
1077+
1078+
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.
1079+
1080+
Middleware must be a valid PHP callable that accepts a PSR-7 `ServerRequestInterface` as the first argument and a `callable` as the second argument.
1081+
1082+
```php
1083+
use Psr\Http\Message\ServerRequestInterface;
1084+
use Psr\Http\Message\ResponseInterface;
1085+
use React\Promise\PromiseInterface;
1086+
1087+
class AuthMiddleware
1088+
{
1089+
public function __invoke(ServerRequestInterface $request, callable $next)
1090+
{
1091+
$apiKey = $request->getHeaderLine('Authorization');
1092+
if (empty($apiKey)) {
1093+
return new Response(401, [], 'Authorization required');
1094+
}
1095+
1096+
$request = $request->withAttribute('user_id', $this->validateApiKey($apiKey));
1097+
$result = $next($request);
1098+
1099+
return match (true) {
1100+
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
1101+
$result instanceof ResponseInterface => $this->handle($result),
1102+
default => $result
1103+
};
1104+
}
1105+
1106+
private function handle($response)
1107+
{
1108+
return $response instanceof ResponseInterface
1109+
? $response->withHeader('X-Auth-Provider', 'mcp-server')
1110+
: $response;
1111+
}
1112+
}
1113+
1114+
$middlewares = [
1115+
new AuthMiddleware(),
1116+
new LoggingMiddleware(),
1117+
function(ServerRequestInterface $request, callable $next) {
1118+
$result = $next($request);
1119+
return match (true) {
1120+
$result instanceof PromiseInterface => $result->then(function($response) {
1121+
return $response instanceof ResponseInterface
1122+
? $response->withHeader('Access-Control-Allow-Origin', '*')
1123+
: $response;
1124+
}),
1125+
$result instanceof ResponseInterface => $result->withHeader('Access-Control-Allow-Origin', '*'),
1126+
default => $result
1127+
};
1128+
}
1129+
];
1130+
1131+
$transport = new StreamableHttpServerTransport(
1132+
host: '127.0.0.1',
1133+
port: 8080,
1134+
middlewares: $middlewares
1135+
);
1136+
```
1137+
1138+
**Important Considerations:**
1139+
1140+
- **Response Handling**: Middleware must handle both synchronous `ResponseInterface` and asynchronous `PromiseInterface` returns from `$next($request)`, since ReactPHP operates asynchronously
1141+
- **Invokable Pattern**: The recommended pattern is to use invokable classes with a separate `handle()` method to process responses, making the async logic reusable
1142+
- **Execution Order**: Middleware executes in the order provided, with the last middleware being closest to your MCP handlers
1143+
10761144
### SSL Context Configuration
10771145

10781146
For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options:

src/Transports/HttpServerTransport.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,25 @@ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterf
6262
* @param int $port Port to listen on (e.g., 8080).
6363
* @param string $mcpPathPrefix URL prefix for MCP endpoints (e.g., 'mcp').
6464
* @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS).
65+
* @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
6566
*/
6667
public function __construct(
6768
private readonly string $host = '127.0.0.1',
6869
private readonly int $port = 8080,
6970
private readonly string $mcpPathPrefix = 'mcp',
7071
private readonly ?array $sslContext = null,
72+
private array $middlewares = []
7173
) {
7274
$this->logger = new NullLogger();
7375
$this->loop = Loop::get();
7476
$this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse';
7577
$this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message';
78+
79+
foreach ($this->middlewares as $mw) {
80+
if (!is_callable($mw)) {
81+
throw new \InvalidArgumentException('All provided middlewares must be callable.');
82+
}
83+
}
7684
}
7785

7886
public function setLogger(LoggerInterface $logger): void
@@ -114,7 +122,8 @@ public function listen(): void
114122
$this->loop
115123
);
116124

117-
$this->http = new HttpServer($this->loop, $this->createRequestHandler());
125+
$handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
126+
$this->http = new HttpServer($this->loop, ...$handlers);
118127
$this->http->listen($this->socket);
119128

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

264-
$this->emit('message', [$message, $sessionId]);
273+
$context = [
274+
'request' => $request,
275+
];
276+
$this->emit('message', [$message, $sessionId, $context]);
265277

266278
return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted');
267279
}

src/Transports/StreamableHttpServerTransport.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA
6767

6868
/**
6969
* @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream.
70+
* @param bool $stateless If true, the server will not emit client_connected events.
71+
* @param EventStoreInterface $eventStore If provided, the server will replay events to the client.
72+
* @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
7073
* This can be useful for simple request/response scenarios without streaming.
7174
*/
7275
public function __construct(
@@ -76,12 +79,19 @@ public function __construct(
7679
private ?array $sslContext = null,
7780
private readonly bool $enableJsonResponse = true,
7881
private readonly bool $stateless = false,
79-
?EventStoreInterface $eventStore = null
82+
?EventStoreInterface $eventStore = null,
83+
private array $middlewares = []
8084
) {
8185
$this->logger = new NullLogger();
8286
$this->loop = Loop::get();
8387
$this->mcpPath = '/' . trim($mcpPath, '/');
8488
$this->eventStore = $eventStore;
89+
90+
foreach ($this->middlewares as $mw) {
91+
if (!is_callable($mw)) {
92+
throw new \InvalidArgumentException('All provided middlewares must be callable.');
93+
}
94+
}
8595
}
8696

8797
protected function generateId(): string
@@ -119,7 +129,8 @@ public function listen(): void
119129
$this->loop
120130
);
121131

122-
$this->http = new HttpServer($this->loop, $this->createRequestHandler());
132+
$handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
133+
$this->http = new HttpServer($this->loop, ...$handlers);
123134
$this->http->listen($this->socket);
124135

125136
$this->socket->on('error', function (Throwable $error) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\General;
6+
7+
use PhpMcp\Schema\Content\TextContent;
8+
use PhpMcp\Server\Context;
9+
10+
class RequestAttributeChecker
11+
{
12+
public function checkAttribute(Context $context): TextContent
13+
{
14+
$attribute = $context->request->getAttribute('middleware-attr');
15+
if ($attribute === 'middleware-value') {
16+
return TextContent::make('middleware-value-found: ' . $attribute);
17+
}
18+
19+
return TextContent::make('middleware-value-not-found: ' . $attribute);
20+
}
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
class ErrorMiddleware
10+
{
11+
public function __invoke(ServerRequestInterface $request, callable $next)
12+
{
13+
if (str_contains($request->getUri()->getPath(), '/error-middleware')) {
14+
throw new \Exception('Middleware error');
15+
}
16+
return $next($request);
17+
}
18+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use React\Promise\PromiseInterface;
10+
11+
class FirstMiddleware
12+
{
13+
public function __invoke(ServerRequestInterface $request, callable $next)
14+
{
15+
$result = $next($request);
16+
17+
return match (true) {
18+
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
19+
$result instanceof ResponseInterface => $this->handle($result),
20+
default => $result
21+
};
22+
}
23+
24+
private function handle($response)
25+
{
26+
if ($response instanceof ResponseInterface) {
27+
$existing = $response->getHeaderLine('X-Middleware-Order');
28+
$new = $existing ? $existing . ',first' : 'first';
29+
return $response->withHeader('X-Middleware-Order', $new);
30+
}
31+
return $response;
32+
}
33+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use React\Promise\PromiseInterface;
10+
11+
class HeaderMiddleware
12+
{
13+
public function __invoke(ServerRequestInterface $request, callable $next)
14+
{
15+
$result = $next($request);
16+
17+
return match (true) {
18+
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
19+
$result instanceof ResponseInterface => $this->handle($result),
20+
default => $result
21+
};
22+
}
23+
24+
private function handle($response)
25+
{
26+
return $response instanceof ResponseInterface
27+
? $response->withHeader('X-Test-Middleware', 'header-added')
28+
: $response;
29+
}
30+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
class RequestAttributeMiddleware
10+
{
11+
public function __invoke(ServerRequestInterface $request, callable $next)
12+
{
13+
$request = $request->withAttribute('middleware-attr', 'middleware-value');
14+
return $next($request);
15+
}
16+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use React\Promise\PromiseInterface;
10+
11+
class SecondMiddleware
12+
{
13+
public function __invoke(ServerRequestInterface $request, callable $next)
14+
{
15+
$result = $next($request);
16+
17+
return match (true) {
18+
$result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
19+
$result instanceof ResponseInterface => $this->handle($result),
20+
default => $result
21+
};
22+
}
23+
24+
private function handle($response)
25+
{
26+
if ($response instanceof ResponseInterface) {
27+
$existing = $response->getHeaderLine('X-Middleware-Order');
28+
$new = $existing ? $existing . ',second' : 'second';
29+
return $response->withHeader('X-Middleware-Order', $new);
30+
}
31+
return $response;
32+
}
33+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use React\Http\Message\Response;
9+
10+
class ShortCircuitMiddleware
11+
{
12+
public function __invoke(ServerRequestInterface $request, callable $next)
13+
{
14+
if (str_contains($request->getUri()->getPath(), '/short-circuit')) {
15+
return new Response(418, [], 'Short-circuited by middleware');
16+
}
17+
return $next($request);
18+
}
19+
}

0 commit comments

Comments
 (0)