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
47 changes: 47 additions & 0 deletions src/Server/Transport/Http/MiddlewareRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Server\Transport\Http;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* A request handler that processes a middleware pipeline before dispatching
* the request to the core transport handler.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*
* @internal
*/
class MiddlewareRequestHandler implements RequestHandlerInterface
{
/**
* @param list<MiddlewareInterface> $middleware
*/
public function __construct(
private array $middleware,
private \Closure $application,
) {
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
$middleware = array_shift($this->middleware);
if (null === $middleware) {
return ($this->application)($request);
}

return $middleware->process($request, $this);
}
}
65 changes: 47 additions & 18 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
namespace Mcp\Server\Transport;

use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;

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

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

/**
* @param array<string, string> $corsHeaders
* @param array<string, string> $corsHeaders
* @param iterable<MiddlewareInterface> $middleware
*/
public function __construct(
private readonly ServerRequestInterface $request,
private ServerRequestInterface $request,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
array $corsHeaders = [],
?LoggerInterface $logger = null,
iterable $middleware = [],
) {
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 +65,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 ($middleware as $m) {
if (!$m instanceof MiddlewareInterface) {
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
}
$this->middleware[] = $m;
}
}

public function send(string $data, array $context): void
Expand All @@ -69,17 +82,17 @@ 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 = new MiddlewareRequestHandler(
$this->middleware,
\Closure::fromCallable([$this, 'handleRequest']),
);

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

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

protected function handlePostRequest(): ResponseInterface
Expand All @@ -92,7 +105,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 +125,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 +147,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 +214,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 @@ -246,15 +259,31 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
$response = $response->withHeader('Allow', 'POST, DELETE, OPTIONS');
}

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),
};
}
}
Loading