Skip to content
Open
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
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"psr/event-dispatcher": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
Expand Down
40 changes: 40 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,46 @@ Default CORS headers:
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept`

### PSR-15 Middleware

`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log,
enforce auth, or short-circuit with a response for any HTTP method.

```php
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private ResponseFactoryInterface $responses)
{
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler)
{
if (!$request->hasHeader('Authorization')) {
return $this->responses->createResponse(401);
}

return $handler->handle($request);
}
}

$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
[],
$logger,
[new AuthMiddleware($responseFactory)],
);
```

If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.

### Architecture

The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
Expand Down
94 changes: 77 additions & 17 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;

Expand All @@ -36,19 +38,22 @@ class StreamableHttpTransport extends BaseTransport
/** @var array<string, string> */
private array $corsHeaders;

/** @var list<MiddlewareInterface> */
private array $middlewares = [];

/**
* @param array<string, string> $corsHeaders
* @param iterable<MiddlewareInterface> $middlewares
*/
public function __construct(
private readonly ServerRequestInterface $request,
private ServerRequestInterface $request,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
array $corsHeaders = [],
?LoggerInterface $logger = null,
iterable $middlewares = [],
) {
parent::__construct($logger);
$sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id');
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;

$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
Expand All @@ -59,6 +64,13 @@ public function __construct(
'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept',
'Access-Control-Expose-Headers' => 'Mcp-Session-Id',
], $corsHeaders);

foreach ($middlewares as $middleware) {
if (!$middleware instanceof MiddlewareInterface) {
throw new \InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
}
$this->middlewares[] = $middleware;
}
}

public function send(string $data, array $context): void
Expand All @@ -69,17 +81,15 @@ public function send(string $data, array $context): void

public function listen(): ResponseInterface
{
return match ($this->request->getMethod()) {
'OPTIONS' => $this->handleOptionsRequest(),
'POST' => $this->handlePostRequest(),
'DELETE' => $this->handleDeleteRequest(),
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
};
$handler = $this->createRequestHandler();
$response = $handler->handle($this->request);

return $this->withCorsHeaders($response);
}

protected function handleOptionsRequest(): ResponseInterface
{
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
return $this->responseFactory->createResponse(204);
}

protected function handlePostRequest(): ResponseInterface
Expand All @@ -92,7 +102,7 @@ protected function handlePostRequest(): ResponseInterface
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($this->immediateResponse));

return $this->withCorsHeaders($response);
return $response;
}

if (null !== $this->sessionFiber) {
Expand All @@ -112,15 +122,15 @@ protected function handleDeleteRequest(): ResponseInterface

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

return $this->withCorsHeaders($this->responseFactory->createResponse(200));
return $this->responseFactory->createResponse(200);
}

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

if (empty($outgoingMessages)) {
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
return $this->responseFactory->createResponse(202);
}

$messages = array_column($outgoingMessages, 'message');
Expand All @@ -134,7 +144,7 @@ protected function createJsonResponse(): ResponseInterface
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
}

return $this->withCorsHeaders($response);
return $response;
}

protected function createStreamedResponse(): ResponseInterface
Expand Down Expand Up @@ -201,7 +211,7 @@ protected function createStreamedResponse(): ResponseInterface
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
}

return $this->withCorsHeaders($response);
return $response;
}

protected function handleFiberTermination(): void
Expand Down Expand Up @@ -242,15 +252,65 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($payload));

return $this->withCorsHeaders($response);
return $response;
}

protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
{
foreach ($this->corsHeaders as $name => $value) {
$response = $response->withHeader($name, $value);
if (!$response->hasHeader($name)) {
$response = $response->withHeader($name, $value);
}
}

return $response;
}

private function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->request = $request;
$sessionIdString = $request->getHeaderLine('Mcp-Session-Id');
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;

return match ($request->getMethod()) {
'OPTIONS' => $this->handleOptionsRequest(),
'POST' => $this->handlePostRequest(),
'DELETE' => $this->handleDeleteRequest(),
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
};
}

private function createRequestHandler(): RequestHandlerInterface
{
/**
* @see self::handleRequest
*/
$handler = new class(\Closure::fromCallable([$this, 'handleRequest'])) implements RequestHandlerInterface {
public function __construct(private \Closure $handler)
{
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
return ($this->handler)($request);
}
};

foreach (array_reverse($this->middlewares) as $middleware) {
$handler = new class($middleware, $handler) implements RequestHandlerInterface {
public function __construct(
private MiddlewareInterface $middleware,
private RequestHandlerInterface $handler,
) {
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->middleware->process($request, $this->handler);
}
};
}

return $handler;
}
}
Loading
Loading