docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras.
19 KiB
Transports
Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport implementations: STDIO for command-line integration and HTTP for web-based communication.
Table of Contents
Transport Overview
All transports implement the TransportInterface and follow the same basic pattern:
$server = Server::builder()
->setServerInfo('My Server', '1.0.0')
->setDiscovery(__DIR__, ['.'])
->build();
$transport = new SomeTransport();
$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP
STDIO Transport
The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations.
$transport = new StdioTransport(
input: STDIN, // Input stream (default: STDIN)
output: STDOUT, // Output stream (default: STDOUT)
logger: $logger // Optional PSR-3 logger
);
Parameters
input(optional): Input stream resource. Defaults toSTDIN.output(optional): Output stream resource. Defaults toSTDOUT.logger(optional):LoggerInterface- PSR-3 logger for debugging. Defaults toNullLogger.
Important
When using STDIO transport, never write to
STDOUTin your handlers as it's reserved for JSON-RPC communication. UseSTDERRfor debugging instead.
Example Server Script
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('STDIO Calculator', '1.0.0')
->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers')
->addTool(InvokableCalculator::class)
->build();
$transport = new StdioTransport();
$status = $server->run($transport);
exit($status); // 0 on clean shutdown, non-zero if STDIN errored
Client Configuration
For MCP clients like Claude Desktop:
{
"mcpServers": {
"my-php-server": {
"command": "php",
"args": ["/absolute/path/to/server.php"]
}
}
}
HTTP Transport
The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application.
use Psr\Http\Message\ServerRequestInterface;
// PSR-17 factories are automatically discovered
$transport = new StreamableHttpTransport(
request: $serverRequest, // PSR-7 server request
responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null)
streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null)
logger: $logger // Optional PSR-3 logger
);
Parameters
request(required):ServerRequestInterface- The incoming PSR-7 HTTP requestresponseFactory(optional):ResponseFactoryInterface- PSR-17 factory for creating HTTP responses. Auto-discovered if not provided.streamFactory(optional):StreamFactoryInterface- PSR-17 factory for creating response body streams. Auto-discovered if not provided.logger(optional):LoggerInterface- PSR-3 logger for debugging. Defaults toNullLogger.middleware(optional):iterable<MiddlewareInterface>|null- PSR-15 middleware chain.null(omitted) installs the default stack.[]disables all defaults — useful when the surrounding application already handles CORS, host validation, etc.maxBodyBytes(optional):int- Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES). See Request Body Size Limit.
PSR-17 Auto-Discovery
The transport automatically discovers PSR-17 factory implementations from these popular packages:
nyholm/psr7guzzlehttp/psr7slim/psr7laminas/laminas-diactoros- And other PSR-17 compatible implementations
# Install any PSR-17 package - discovery works automatically
composer require nyholm/psr7
If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly:
use Nyholm\Psr7\Factory\Psr17Factory;
$psr17Factory = new Psr17Factory();
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
Default Middleware
When the middleware argument is omitted (or set to null), the transport installs a secure default stack:
| Order | Middleware | Purpose |
|---|---|---|
| 1 | CorsMiddleware |
Applies CORS headers to every response. By default does not set Access-Control-Allow-Origin (cross-origin requests are blocked). |
| 2 | DnsRebindingProtectionMiddleware |
Validates Origin/Host against an allowlist. Defaults to localhost variants only. |
// Zero-config, secure-by-default — local servers get full protection automatically.
$transport = new StreamableHttpTransport($request);
The default stack can be inspected and recomposed via the public factory:
$middleware = StreamableHttpTransport::defaultMiddleware();
These run at the edge, before the request's protocol era is known, because what they enforce is true of
both eras. ProtocolVersionMiddleware is not in that stack: the MCP-Protocol-Version header rule belongs
to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic,
and the modern leg answers for its own revisions. It is available as
StreamableHttpTransport::handshakeMiddleware() and is applied whether or not you replace the edge stack.
See Serving both eras.
CORS Configuration
CORS is handled by CorsMiddleware. To enable cross-origin browser requests, configure it explicitly and pass it
in place of (or alongside) the defaults:
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
use Mcp\Server\Transport\StreamableHttpTransport;
// Reflect a specific origin
$transport = new StreamableHttpTransport(
$request,
middleware: [
new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
new DnsRebindingProtectionMiddleware(),
new ProtocolVersionMiddleware(),
],
);
// Allow all origins (development only)
$transport = new StreamableHttpTransport(
$request,
middleware: [
new CorsMiddleware(allowedOrigins: ['*']),
new DnsRebindingProtectionMiddleware(),
new ProtocolVersionMiddleware(),
],
);
When the allowlist is a concrete set of origins (not ['*']), CorsMiddleware automatically adds Vary: Origin
so shared caches/CDNs do not serve a response generated for one origin to a request from another.
Headers already present on a response (e.g. set by inner middleware) are preserved — CorsMiddleware only adds
defaults when they are absent.
Important
Access-Control-Allow-Origin: *is incompatible with credentialed browser requests (those carryingAuthorization, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves a browser client, configureallowedOriginswith the explicit origin(s) you trust rather than['*']. The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials.
DNS Rebinding Protection
DnsRebindingProtectionMiddleware validates the Origin header against an allowlist (falling back to Host
when Origin is absent). The default allowlist is localhost-only:
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']);
If the server is fronted by a reverse proxy that already validates Host, drop this middleware from the chain
or supply a permissive allowlist.
Protocol Version Validation
ProtocolVersionMiddleware rejects requests whose MCP-Protocol-Version header is not in the SDK's supported
set with 400 Bad Request. Requests without the header pass through, since the initialize round-trip and some
legacy clients do not send it.
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
// Only accept the latest spec version
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
The default set is ProtocolVersion::handshakeVersions() — every revision the server can actually negotiate over
initialize, rather than every revision the enum declares. A request without the header is treated as
ProtocolVersion::DEFAULT_HEADER_VERSION (2025-03-26), the revision that introduced both Streamable HTTP and the
header itself, so a header-less request cannot be newer than that.
This header check is separate from, and happens after, the handshake itself. See
Protocol Version Negotiation for how the revision is agreed in the
first place. Being separate also means it is unaffected by setProtocolVersion(): the middleware validates against
the set it was constructed with, not against the revision a given session negotiated, so a server that pins the
handshake has to pass that revision here as well.
Request Body Size Limit
StreamableHttpTransport caps the POST body it reads to guard against memory exhaustion from an oversized or
unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with 413 and never reaches
message parsing.
use Mcp\Server\Transport\StreamableHttpTransport;
// Raise the cap to 16 MiB
$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024);
When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with
unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot
exhaust memory. A value below 1 throws InvalidArgumentException.
JSON-RPC Batch Size Limit
A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any
message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on
MessageFactory:
use Mcp\JsonRpc\MessageFactory;
$factory = MessageFactory::make(maxBatchSize: 50);
Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array
is a batch. Scalars, empty payloads, and non-object batch elements are returned as InvalidInputMessageException
entries (the existing per-message error contract), not parse errors or crashes. A maxBatchSize below 1 throws
InvalidArgumentException.
Custom PSR-15 Middleware
StreamableHttpTransport accepts any PSR-15 middleware chain. To extend the defaults, spread them and append
your own middleware — the defaults stay outermost so CORS headers are applied to every response, including
short-circuited ones:
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
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): ResponseInterface
{
if (!$request->hasHeader('Authorization')) {
return $this->responses->createResponse(401);
}
return $handler->handle($request);
}
}
$transport = new StreamableHttpTransport(
$request,
logger: $logger,
middleware: [
...StreamableHttpTransport::defaultMiddleware(),
new AuthMiddleware($responseFactory),
],
);
To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\StreamableHttpTransport;
$transport = new StreamableHttpTransport(
$request,
middleware: [
...array_filter(
StreamableHttpTransport::defaultMiddleware(),
fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
),
new AuthMiddleware($responseFactory),
],
);
Pass middleware: [] to disable every default and run only your own chain:
$transport = new StreamableHttpTransport(
$request,
middleware: [new AuthMiddleware($responseFactory)],
);
Architecture
The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that your application can handle however it needs to:
Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App
This design allows integration with any PHP framework or application that supports PSR-7.
Basic Usage (Standalone)
Here's a simplified example using PSR-17 discovery and Laminas emitter:
use Http\Discovery\Psr17Factory;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Mcp\Server\Session\FileSessionStore;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
$psr17Factory = new Psr17Factory();
$request = $psr17Factory->createServerRequestFromGlobals();
$server = Server::builder()
->setServerInfo('HTTP Server', '1.0.0')
->setDiscovery(__DIR__, ['.'])
->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions
->build();
$transport = new StreamableHttpTransport($request);
$response = $server->run($transport);
(new SapiEmitter())->emit($response);
Framework Integration
Symfony Integration
First install the required PSR libraries:
composer require symfony/psr-http-message-bridge nyholm/psr7
Then create a controller that uses Symfony's PSR-7 bridge:
Note
: This example assumes your MCP
Serverinstance is configured in Symfony's service container.
// In a Symfony controller
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
class McpController
{
#[Route('/mcp', name: 'mcp_endpoint')]
public function handle(Request $request, Server $server): Response
{
// Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered)
$psrHttpFactory = new PsrHttpFactory();
$httpFoundationFactory = new HttpFoundationFactory();
$psrRequest = $psrHttpFactory->createRequest($request);
// Process with MCP (factories auto-discovered)
$transport = new StreamableHttpTransport($psrRequest);
$psrResponse = $server->run($transport);
// Convert PSR-7 response back to Symfony
return $httpFoundationFactory->createResponse($psrResponse);
}
}
Laravel Integration
First install the required PSR libraries:
composer require symfony/psr-http-message-bridge nyholm/psr7
Then create a controller that type-hints ServerRequestInterface:
Note
: This example assumes your MCP
Serverinstance is constructed and bound in a Laravel service provider for dependency injection.
// In a Laravel controller
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
class McpController
{
public function handle(ServerRequestInterface $request, Server $server): ResponseInterface
{
// Create the MCP HTTP transport
$transport = new StreamableHttpTransport($request);
// Process MCP request and return PSR-7 response
// Laravel automatically handles PSR-7 responses
return $server->run($transport);
}
}
// Route registration
Route::any('/mcp', [McpController::class, 'handle']);
Slim Framework Integration
Slim Framework works natively with PSR-7.
Create a route handler using Slim's built-in factories and container:
use Slim\Factory\AppFactory;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
$app = AppFactory::create();
$app->any('/mcp', function ($request, $response) {
$server = Server::builder()
->setServerInfo('My MCP Server', '1.0.0')
->setDiscovery(__DIR__, ['.'])
->build();
$transport = new StreamableHttpTransport($request);
return $server->run($transport);
});
HTTP Method Handling
The transport handles all HTTP methods automatically:
- POST: Send MCP requests
- GET: Not implemented (returns 405)
- DELETE: End session
- OPTIONS: CORS preflight
You should route all methods to your MCP endpoint, not just POST.
Session Management
HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport where in-memory sessions work fine, HTTP transport needs a persistent session store:
use Mcp\Server\Session\FileSessionStore;
// ✅ Good for HTTP
$server = Server::builder()
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
->build();
// ❌ Not recommended for HTTP (sessions lost between requests)
$server = Server::builder()
->setSession(new InMemorySessionStore())
->build();
Recommended Route
It's recommended to mount the MCP endpoint at /mcp, but this is not enforced:
// Recommended
Route::any('/mcp', [McpController::class, 'handle']);
// Also valid
Route::any('/', [McpController::class, 'handle']);
Route::any('/api/mcp', [McpController::class, 'handle']);
Testing HTTP Transport
Use the MCP Inspector to test HTTP servers:
# Start your PHP server
php -S localhost:8000 server.php
# Connect with MCP Inspector
npx @modelcontextprotocol/inspector http://localhost:8000
Choosing a Transport
The choice between STDIO and HTTP transport depends on the client you want to integrate with. If you are integrating with a client that is running locally (like Claude Desktop), use STDIO. If you are building a server in a distributed environment and need to integrate with a remote client, use Streamable HTTP.