Skip to content

Commit 00947fb

Browse files
sveneldVolodymyr Panivko
authored andcommitted
Add Middleware handlers to StreamableHttpTransport
1 parent f3ecb2e commit 00947fb

File tree

5 files changed

+309
-18
lines changed

5 files changed

+309
-18
lines changed

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"psr/event-dispatcher": "^1.0",
2929
"psr/http-factory": "^1.1",
3030
"psr/http-message": "^1.1 || ^2.0",
31+
"psr/http-server-handler": "^1.0",
32+
"psr/http-server-middleware": "^1.0",
3133
"psr/log": "^1.0 || ^2.0 || ^3.0",
3234
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
3335
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"

docs/transports.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,46 @@ Default CORS headers:
179179
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
180180
- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept`
181181

182+
### PSR-15 Middleware
183+
184+
`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log,
185+
enforce auth, or short-circuit with a response for any HTTP method.
186+
187+
```php
188+
use Mcp\Server\Transport\StreamableHttpTransport;
189+
use Psr\Http\Message\ResponseFactoryInterface;
190+
use Psr\Http\Message\ServerRequestInterface;
191+
use Psr\Http\Server\MiddlewareInterface;
192+
use Psr\Http\Server\RequestHandlerInterface;
193+
194+
final class AuthMiddleware implements MiddlewareInterface
195+
{
196+
public function __construct(private ResponseFactoryInterface $responses)
197+
{
198+
}
199+
200+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler)
201+
{
202+
if (!$request->hasHeader('Authorization')) {
203+
return $this->responses->createResponse(401);
204+
}
205+
206+
return $handler->handle($request);
207+
}
208+
}
209+
210+
$transport = new StreamableHttpTransport(
211+
$request,
212+
$responseFactory,
213+
$streamFactory,
214+
[],
215+
$logger,
216+
[new AuthMiddleware($responseFactory)],
217+
);
218+
```
219+
220+
If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.
221+
182222
### Architecture
183223

184224
The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http;
13+
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\ServerRequestInterface;
16+
use Psr\Http\Server\MiddlewareInterface;
17+
use Psr\Http\Server\RequestHandlerInterface;
18+
19+
/**
20+
* A request handler that processes a middleware pipeline before dispatching
21+
* the request to the core transport handler.
22+
*
23+
* @author Volodymyr Panivko <sveneld300@gmail.com>
24+
* @internal
25+
*/
26+
class MiddlewareRequestHandler implements RequestHandlerInterface
27+
{
28+
/**
29+
* @param list<MiddlewareInterface> $middleware
30+
*/
31+
public function __construct(
32+
private array $middleware,
33+
private \Closure $application,
34+
) {
35+
}
36+
37+
public function handle(ServerRequestInterface $request): ResponseInterface
38+
{
39+
$middleware = array_shift($this->middleware);
40+
if (null === $middleware) {
41+
return ($this->application)($request);
42+
}
43+
44+
return $middleware->process($request, $this);
45+
}
46+
}

src/Server/Transport/StreamableHttpTransport.php

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@
1212
namespace Mcp\Server\Transport;
1313

1414
use Http\Discovery\Psr17FactoryDiscovery;
15+
use Mcp\Exception\InvalidArgumentException;
1516
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
1618
use Psr\Http\Message\ResponseFactoryInterface;
1719
use Psr\Http\Message\ResponseInterface;
1820
use Psr\Http\Message\ServerRequestInterface;
1921
use Psr\Http\Message\StreamFactoryInterface;
22+
use Psr\Http\Server\MiddlewareInterface;
23+
use Psr\Http\Server\RequestHandlerInterface;
2024
use Psr\Log\LoggerInterface;
2125
use Symfony\Component\Uid\Uuid;
2226

@@ -36,19 +40,22 @@ class StreamableHttpTransport extends BaseTransport
3640
/** @var array<string, string> */
3741
private array $corsHeaders;
3842

43+
/** @var list<MiddlewareInterface> */
44+
private array $middleware = [];
45+
3946
/**
40-
* @param array<string, string> $corsHeaders
47+
* @param array<string, string> $corsHeaders
48+
* @param iterable<MiddlewareInterface> $middleware
4149
*/
4250
public function __construct(
43-
private readonly ServerRequestInterface $request,
51+
private ServerRequestInterface $request,
4452
?ResponseFactoryInterface $responseFactory = null,
4553
?StreamFactoryInterface $streamFactory = null,
4654
array $corsHeaders = [],
4755
?LoggerInterface $logger = null,
56+
iterable $middleware = [],
4857
) {
4958
parent::__construct($logger);
50-
$sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id');
51-
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
5259

5360
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
5461
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
@@ -59,6 +66,13 @@ public function __construct(
5966
'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept',
6067
'Access-Control-Expose-Headers' => 'Mcp-Session-Id',
6168
], $corsHeaders);
69+
70+
foreach ($middleware as $m) {
71+
if (!$m instanceof MiddlewareInterface) {
72+
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
73+
}
74+
$this->middleware[] = $m;
75+
}
6276
}
6377

6478
public function send(string $data, array $context): void
@@ -69,17 +83,15 @@ public function send(string $data, array $context): void
6983

7084
public function listen(): ResponseInterface
7185
{
72-
return match ($this->request->getMethod()) {
73-
'OPTIONS' => $this->handleOptionsRequest(),
74-
'POST' => $this->handlePostRequest(),
75-
'DELETE' => $this->handleDeleteRequest(),
76-
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
77-
};
86+
$handler = $this->createRequestHandler();
87+
$response = $handler->handle($this->request);
88+
89+
return $this->withCorsHeaders($response);
7890
}
7991

8092
protected function handleOptionsRequest(): ResponseInterface
8193
{
82-
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
94+
return $this->responseFactory->createResponse(204);
8395
}
8496

8597
protected function handlePostRequest(): ResponseInterface
@@ -92,7 +104,7 @@ protected function handlePostRequest(): ResponseInterface
92104
->withHeader('Content-Type', 'application/json')
93105
->withBody($this->streamFactory->createStream($this->immediateResponse));
94106

95-
return $this->withCorsHeaders($response);
107+
return $response;
96108
}
97109

98110
if (null !== $this->sessionFiber) {
@@ -112,15 +124,15 @@ protected function handleDeleteRequest(): ResponseInterface
112124

113125
$this->handleSessionEnd($this->sessionId);
114126

115-
return $this->withCorsHeaders($this->responseFactory->createResponse(200));
127+
return $this->responseFactory->createResponse(200);
116128
}
117129

118130
protected function createJsonResponse(): ResponseInterface
119131
{
120132
$outgoingMessages = $this->getOutgoingMessages($this->sessionId);
121133

122134
if (empty($outgoingMessages)) {
123-
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
135+
return $this->responseFactory->createResponse(202);
124136
}
125137

126138
$messages = array_column($outgoingMessages, 'message');
@@ -134,7 +146,7 @@ protected function createJsonResponse(): ResponseInterface
134146
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
135147
}
136148

137-
return $this->withCorsHeaders($response);
149+
return $response;
138150
}
139151

140152
protected function createStreamedResponse(): ResponseInterface
@@ -201,7 +213,7 @@ protected function createStreamedResponse(): ResponseInterface
201213
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
202214
}
203215

204-
return $this->withCorsHeaders($response);
216+
return $response;
205217
}
206218

207219
protected function handleFiberTermination(): void
@@ -246,15 +258,42 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
246258
$response = $response->withHeader('Allow', 'POST, DELETE, OPTIONS');
247259
}
248260

249-
return $this->withCorsHeaders($response);
261+
return $response;
250262
}
251263

252264
protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
253265
{
254266
foreach ($this->corsHeaders as $name => $value) {
255-
$response = $response->withHeader($name, $value);
267+
if (!$response->hasHeader($name)) {
268+
$response = $response->withHeader($name, $value);
269+
}
256270
}
257271

258272
return $response;
259273
}
274+
275+
private function handleRequest(ServerRequestInterface $request): ResponseInterface
276+
{
277+
$this->request = $request;
278+
$sessionIdString = $request->getHeaderLine('Mcp-Session-Id');
279+
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
280+
281+
return match ($request->getMethod()) {
282+
'OPTIONS' => $this->handleOptionsRequest(),
283+
'POST' => $this->handlePostRequest(),
284+
'DELETE' => $this->handleDeleteRequest(),
285+
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
286+
};
287+
}
288+
289+
private function createRequestHandler(): RequestHandlerInterface
290+
{
291+
/**
292+
* @see self::handleRequest
293+
*/
294+
return new MiddlewareRequestHandler(
295+
$this->middleware,
296+
\Closure::fromCallable([$this, 'handleRequest']),
297+
);
298+
}
260299
}

0 commit comments

Comments
 (0)