php-sdk/docs/transports.md
Christopher Hertel eb20453f49
[Client][Schema][Server] Implement protocol version negotiation (#403)
* [Client][Docs][Schema][Server] Implement protocol version negotiation

The `initialize` handler never looked at the version the client asked for:
it always answered with the server's configured version (or the SDK default),
so a client offering a revision the server supports could still be told to
speak a different one. The spec requires echoing back a supported request and
only counter-offering when the requested version cannot be honoured.

Turns ProtocolVersion into the registry the other SDKs converged on
(typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py):
`latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered
comparison through `isAtLeast()`. Comparison goes through declaration order
rather than string collation, so an unrecognized peer string compares
conservatively instead of accidentally sorting above a known revision. The
one throw the enum can produce is the SDK's own LogicException, so a consumer
catching `Mcp\Exception\ExceptionInterface` does not miss it.

Adds 2026-07-28 as a known revision and splits the set into two eras:
handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern
(2026-07-28+, per-request `_meta`). The lists are kept apart so a modern
revision can never leak into an `initialize` exchange, which has no
`initialize` at all. Serving the modern era is follow-up work; this only
teaches the SDK that the revision exists and must not be mis-negotiated.
Deliberately absent is any "newest revision in any era" accessor, which
would hand back the one revision the handshake must never offer.

Server: negotiates and stores the result on the session. A version pinned in
Configuration still pins the handshake to exactly that revision.
Client: validates the counter-offer and fails the handshake instead of
continuing on a revision it cannot speak; records the negotiated version on
the client state, readable through the new Client::getProtocolVersion().

The HTTP middleware now defaults to the handshake versions rather than every
declared case, so it stops accepting a modern version the server cannot yet
serve. When the header is absent it falls back to
ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that
introduced both Streamable HTTP and the header itself.

The negotiation behaviour is one provideNegotiationTable() data provider
whose rows are the documented table -- supported revision echoed back,
unknown or malformed counter-offered, modern counter-offered -- so the docs
and the suite cannot drift apart. The supported-revision rows iterate
handshakeVersions() rather than a literal list, so declaring a new handshake
revision extends the table automatically, and the "never answer with a modern
revision" assertion runs on every row.

Documented in server-builder.md (the enum registry, how the server answers a
handshake, what pinning changes), transports.md (the header check) and
client.md (the client's side), all linking the spec's versioning section.

* [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated

* Log a warning instead of silently falling back when a modern revision is
  configured on the client or pinned on the server builder.
* Document that setProtocolVersion() does not restrict the MCP-Protocol-Version
  header check, which validates against a fixed set.
* Pin every revision's era in a test, so a new case declared past the modern
  boundary fails instead of dropping out of the handshake set.
* Add CHANGELOG entry.

* [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
2026-08-15 01:41:19 +02:00

18 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 to STDIN.
  • output (optional): Output stream resource. Defaults to STDOUT.
  • logger (optional): LoggerInterface - PSR-3 logger for debugging. Defaults to NullLogger.

Important

When using STDIO transport, never write to STDOUT in your handlers as it's reserved for JSON-RPC communication. Use STDERR for 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 request
  • responseFactory (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 to NullLogger.
  • 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/psr7
  • guzzlehttp/psr7
  • slim/psr7
  • laminas/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.
3 ProtocolVersionMiddleware Rejects requests carrying an unsupported MCP-Protocol-Version header with 400 Bad Request.
// 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();

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 carrying Authorization, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves a browser client, configure allowedOrigins with 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 Server instance 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 Server instance 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();

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.