Skip to content

Commit bb0c6d3

Browse files
committed
Add Middleware handlers to StreamableHttpTransport
1 parent f3ecb2e commit bb0c6d3

5 files changed

Lines changed: 300 additions & 18 deletions

File tree

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
*
25+
* @internal
26+
*/
27+
class MiddlewareRequestHandler implements RequestHandlerInterface
28+
{
29+
/**
30+
* @param list<MiddlewareInterface> $middleware
31+
*/
32+
public function __construct(
33+
private array $middleware,
34+
private \Closure $application,
35+
) {
36+
}
37+
38+
public function handle(ServerRequestInterface $request): ResponseInterface
39+
{
40+
$middleware = array_shift($this->middleware);
41+
if (null === $middleware) {
42+
return ($this->application)($request);
43+
}
44+
45+
return $middleware->process($request, $this);
46+
}
47+
}

src/Server/Transport/StreamableHttpTransport.php

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
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;
2023
use Psr\Log\LoggerInterface;
2124
use Symfony\Component\Uid\Uuid;
2225

@@ -36,19 +39,22 @@ class StreamableHttpTransport extends BaseTransport
3639
/** @var array<string, string> */
3740
private array $corsHeaders;
3841

42+
/** @var list<MiddlewareInterface> */
43+
private array $middleware = [];
44+
3945
/**
40-
* @param array<string, string> $corsHeaders
46+
* @param array<string, string> $corsHeaders
47+
* @param iterable<MiddlewareInterface> $middleware
4148
*/
4249
public function __construct(
43-
private readonly ServerRequestInterface $request,
50+
private ServerRequestInterface $request,
4451
?ResponseFactoryInterface $responseFactory = null,
4552
?StreamFactoryInterface $streamFactory = null,
4653
array $corsHeaders = [],
4754
?LoggerInterface $logger = null,
55+
iterable $middleware = [],
4856
) {
4957
parent::__construct($logger);
50-
$sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id');
51-
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
5258

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

6477
public function send(string $data, array $context): void
@@ -69,17 +82,17 @@ public function send(string $data, array $context): void
6982

7083
public function listen(): ResponseInterface
7184
{
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-
};
85+
$handler = new MiddlewareRequestHandler(
86+
$this->middleware,
87+
\Closure::fromCallable([$this, 'handleRequest']),
88+
);
89+
90+
return $this->withCorsHeaders($handler->handle($this->request));
7891
}
7992

8093
protected function handleOptionsRequest(): ResponseInterface
8194
{
82-
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
95+
return $this->responseFactory->createResponse(204);
8396
}
8497

8598
protected function handlePostRequest(): ResponseInterface
@@ -92,7 +105,7 @@ protected function handlePostRequest(): ResponseInterface
92105
->withHeader('Content-Type', 'application/json')
93106
->withBody($this->streamFactory->createStream($this->immediateResponse));
94107

95-
return $this->withCorsHeaders($response);
108+
return $response;
96109
}
97110

98111
if (null !== $this->sessionFiber) {
@@ -112,15 +125,15 @@ protected function handleDeleteRequest(): ResponseInterface
112125

113126
$this->handleSessionEnd($this->sessionId);
114127

115-
return $this->withCorsHeaders($this->responseFactory->createResponse(200));
128+
return $this->responseFactory->createResponse(200);
116129
}
117130

118131
protected function createJsonResponse(): ResponseInterface
119132
{
120133
$outgoingMessages = $this->getOutgoingMessages($this->sessionId);
121134

122135
if (empty($outgoingMessages)) {
123-
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
136+
return $this->responseFactory->createResponse(202);
124137
}
125138

126139
$messages = array_column($outgoingMessages, 'message');
@@ -134,7 +147,7 @@ protected function createJsonResponse(): ResponseInterface
134147
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
135148
}
136149

137-
return $this->withCorsHeaders($response);
150+
return $response;
138151
}
139152

140153
protected function createStreamedResponse(): ResponseInterface
@@ -201,7 +214,7 @@ protected function createStreamedResponse(): ResponseInterface
201214
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
202215
}
203216

204-
return $this->withCorsHeaders($response);
217+
return $response;
205218
}
206219

207220
protected function handleFiberTermination(): void
@@ -246,15 +259,31 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
246259
$response = $response->withHeader('Allow', 'POST, DELETE, OPTIONS');
247260
}
248261

249-
return $this->withCorsHeaders($response);
262+
return $response;
250263
}
251264

252265
protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
253266
{
254267
foreach ($this->corsHeaders as $name => $value) {
255-
$response = $response->withHeader($name, $value);
268+
if (!$response->hasHeader($name)) {
269+
$response = $response->withHeader($name, $value);
270+
}
256271
}
257272

258273
return $response;
259274
}
275+
276+
private function handleRequest(ServerRequestInterface $request): ResponseInterface
277+
{
278+
$this->request = $request;
279+
$sessionIdString = $request->getHeaderLine('Mcp-Session-Id');
280+
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
281+
282+
return match ($request->getMethod()) {
283+
'OPTIONS' => $this->handleOptionsRequest(),
284+
'POST' => $this->handlePostRequest(),
285+
'DELETE' => $this->handleDeleteRequest(),
286+
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
287+
};
288+
}
260289
}

0 commit comments

Comments
 (0)