Skip to content

Latest commit

 

History

History
287 lines (214 loc) · 13.6 KB

transports.md

File metadata and controls

287 lines (214 loc) · 13.6 KB

Transports

Transports handle the communication layer between client and server.

STDIO Transport

Spawns a server process and communicates via standard input/output:

use Mcp\Client\Transport\StdioTransport;

$transport = new StdioTransport(
    command: 'php',
    args: ['/path/to/server.php'],
    cwd: '/working/directory',     // Optional working directory
    env: ['KEY' => 'value'],       // Optional, replaces the inherited environment
);

Parameters:

  • command (string): The command to execute
  • args (array): Command arguments
  • cwd (string|null): Working directory for the process
  • env (array|null): Environment variables of the process. An array replaces the inherited environment, so pass [...getenv(), 'KEY' => 'value'] to keep it
  • logger (LoggerInterface|null): Optional PSR-3 logger
  • maxBufferSize (int): Maximum buffered bytes per message before the transport gives up

If the server process exits while a request waits for its answer, the request fails at once with a RequestException. The next call throws a ConnectionException until you call connect() again.

On Windows, use PHP 8.5 or newer. With older versions, the client blocks while the server sends no output, and its timeouts have no effect.

Windows starts the command directly, so a batch file like npx must run through cmd:

$transport = new StdioTransport('cmd', ['/c', 'npx', '-y', '@acme/weather-server']);

HTTP Transport

Communicates with remote MCP servers over HTTP:

use Mcp\Client\Transport\HttpTransport;

$transport = new HttpTransport(
    endpoint: 'http://localhost:8000',
    headers: ['X-Tenant' => 'acme'],
);

Parameters:

  • endpoint (string): The MCP server URL
  • headers (array): Additional HTTP headers
  • httpClient (ClientInterface|null): PSR-18 HTTP client (auto-discovered)
  • requestFactory (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered)
  • streamFactory (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered)
  • logger (LoggerInterface|null): Optional PSR-3 logger
  • maxSseBufferBytes (int): Maximum bytes buffered for one incomplete SSE event (default: 8 MiB)
  • listen (bool): Open the server's listening stream after the handshake (see below)
  • reconnectPolicy (ReconnectPolicy|null): How an interrupted SSE stream is resumed (default: 5 attempts, 1–10 s backoff)
  • clock (ClockInterface|null): PSR-20 clock used to schedule reconnects (default: a monotonic clock)
  • maxResponseBodyBytes (int): Maximum bytes read from a response body that is not an SSE stream (default: 8 MiB)
  • auth (AuthenticatorInterface|null): Credentials for a server that requires authorization, see Authorization

When an SSE connection closes before its JSON-RPC response arrives, the transport resumes it with a GET request carrying the latest Last-Event-ID. A stream that sent no event ID can't be resumed, so the request fails at once. A server-provided SSE retry value controls the delay; otherwise the fallback delay doubles from 1 second up to 10 seconds. Completed requests and explicitly closed transports are not reconnected. A server-provided retry value is not capped, so a long one leaves the request waiting until its timeout. A reconnect answered with a redirect or a client error (e.g. 404 for an expired session or 405 without GET support) fails the request at once; only 408, 429, server errors and a successful answer that is not an event stream are retried. Revision 2026-07-28 removed stream resumption, so there an interrupted stream fails the request at once.

To change the attempts or the fallback delays, pass a ReconnectPolicy:

use Mcp\Client\Transport\ReconnectPolicy;

$transport = new HttpTransport(
    endpoint: 'https://example.com/mcp',
    reconnectPolicy: new ReconnectPolicy(maxAttempts: 3, initialDelayMs: 500, maxDelayMs: 5000),
);

maxAttempts: 0 disables resumption: an interrupted stream then fails the request at once instead of waiting for its timeout.

PSR-18 Auto-Discovery:

The transport automatically discovers PSR-18 HTTP clients from:

  • php-http/guzzle7-adapter
  • php-http/curl-client
  • symfony/http-client
  • And other PSR-18 compatible implementations

The transport also discovers PSR-17 factories. php-http/guzzle7-adapter installs them with its dependencies. With php-http/curl-client or symfony/http-client, also install a PSR-17 implementation such as nyholm/psr7. If one is missing, the HttpTransport constructor throws an exception from php-http/discovery.

# brings its own PSR-17 factories
composer require php-http/guzzle7-adapter

# needs a PSR-17 implementation next to it
composer require symfony/http-client nyholm/psr7

Error status codes:

If the server answers with an HTTP error status, the request fails at once with a RequestException. The status 401 or 403 fails with an AuthorizationException.

In the handshake era, the status 404 on a request with a session ID means that the session has expired. The client throws a ConnectionException with the code 404. Call connect() again to start a new session.

Response body size:

The transport reads at most maxResponseBodyBytes from a response body that is not an SSE stream. Raise the limit if your server sends larger results:

$transport = new HttpTransport('http://localhost:8000', maxResponseBodyBytes: 64 * 1024 * 1024);
  • A successful response over the limit fails the request with a RequestException, and the transport closes the body. During connect(), it throws a ConnectionException at once, with no retry and no fallback to the initialize handshake.
  • An error status over the limit fails the request as usual. The transport reads the body up to the limit, and it may log the first 4 KiB.
  • SSE streams have their own limit: maxSseBufferBytes limits the size of one incomplete event.

This limit applies to what the transport reads from the PSR-18 client. What the client does before it returns the response depends on the client:

  • symfony/http-client returns before the whole body has arrived and stores what arrives in php://temp. When the transport closes the body, the download stops. If the server sends without a pause on a very fast connection, the transport can't close the body until it ends, so the whole body is stored on disk. To limit this, set the max_duration option of the client.
  • php-http/guzzle7-adapter and php-http/curl-client write the whole body to php://temp before they return. The transport still uses limited memory, but the client downloads the whole body, stores it on disk and blocks until the body ends. Configure a timeout on these clients to limit this. See Cancellation and deadlines.

Redirects:

If the server answers a request with a 3xx status, the request fails at once with a RequestException that names the status and the Location target. During connect(), it throws a ConnectionException at once, with no retry and no fallback to the initialize handshake. To reach a server that moved, configure its new URL as endpoint.

The transport turns off redirects on a symfony/http-client client, whether it discovers the client or you pass it as httpClient. Any other client keeps its own configuration.

Such a client must not follow redirects to another origin. If it does, your headers and the request body reach that origin, and the transport accepts its answer as the server's. With auth, it must not follow any redirect, because a redirect on the same origin also receives the token.

Guzzle doesn't follow redirects through PSR-18. An HTTPlug adapter around a Guzzle client with the default handler stack (e.g. php-http/guzzle7-adapter), HTTPlug's RedirectPlugin and Symfony's HttplugClient do.

Before symfony/http-client 6.2, the transport can't change a client that you pass. Create it with max_redirects set to 0:

use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

$httpClient = new Psr18Client(HttpClient::create(['max_redirects' => 0]));

!!! note Before symfony/http-client 6.2, the transport replaces a discovered client with a new one. If you configure a client through a custom discovery strategy on those versions, pass it as httpClient instead.

Session ID:

On revision 2025-11-25 and earlier, the server may return an Mcp-Session-Id header with its answer to initialize. The transport ignores this header on any other response. getSessionId() returns it, for example to log it. It returns null on 2026-07-28 and after close():

$sessionId = $transport->getSessionId();

You can't continue a session with a new transport. Every connect() starts a new session, and disconnect() ends it.

Listening for server messages:

Most of what a server sends belongs to one of the client's requests and arrives on that request's response. Up to the 2025 revisions, a server may also send requests and notifications that belong to none of them, like asking for the client's roots, on a standalone GET stream. Pass listen: true to open it:

$transport = new HttpTransport('http://localhost:8000', listen: true);
  • The stream is read while one of the client's requests is in flight. A message that arrives while the client is idle waits for its next request.
  • It needs a PSR-18 client that returns before the response body has ended and whose body can be read without blocking, such as symfony/http-client. A client that buffers the whole body never returns from a stream the server keeps open.
  • A server without a listening stream answers 405, and the connection carries on without one.
  • On 2026-07-28, which has no standalone stream, the option does nothing.

Sending an access token

If you already have an access token (e.g. from a secrets manager), pass it with the auth parameter. To get a token through OAuth, see Authorization.

use Mcp\Client\Auth\BearerToken;
use Mcp\Client\Transport\HttpTransport;

$transport = new HttpTransport(
    'https://mcp.example.com/mcp',
    auth: new BearerToken($accessToken),
);

The transport sends the token in the Authorization header of every request. This value replaces an Authorization header that you pass with headers.

To get credentials in another way, implement Mcp\Client\Auth\AuthenticatorInterface. The transport calls authenticate() for every request. It calls handleChallenge() when the server answers with 401 or 403. Return true to send the request again, at most 3 times, or false to give up.

To authenticate other requests to the same server, see Authenticating other requests.

Cancellation and deadlines

callTool() accepts optional cancellation: ?Mcp\Client\CancellationTokenInterface and timeoutSeconds: ?float arguments. The token's isCancellationRequested() method must return without blocking. The timeout must be finite and positive and replaces the default request timeout for this call. An observed cancellation throws RequestCancelledException. When the per-call or default request timeout runs out, the client throws TimeoutException, a RequestException, and sends notifications/cancelled for the request. Over HTTP on revision 2026-07-28, it sends no notification.

use Mcp\Client\CancellationTokenInterface;

$token = new class implements CancellationTokenInterface {
    public function isCancellationRequested(): bool
    {
        // true once another process has created this file
        return file_exists('/tmp/cancel-weather-lookup');
    }
};

$result = $client->callTool('get_weather', ['city' => 'Paris'], cancellation: $token, timeoutSeconds: 10.0);

Interruption is checked before a request goes out and again once the send returns. The second check is what covers a synchronous application/json answer: the reply is buffered while send() is still on the stack, and a call the caller has given up on must not come back as successful. Its buffered reply is dropped with the pending request, and the connection stays available for later calls.

STDIO checks for interruption while polling the server and sends notifications/cancelled for an interrupted pending request. Late responses are ignored.

HTTP cancellation is cooperative, and what it signals depends on the revision:

  • up to 2025-11-25 a disconnect is not a cancellation, so the client sends notifications/cancelled for the abandoned request. That notification is another request on the same connection and can therefore block; it is best effort, and a failure to send it is logged rather than reported in place of the interruption.
  • from 2026-07-28 closing the request's response stream is the signal, so no separate notification goes out.

Either way the transport closes the active response body on interruption and clears the pending request without closing the MCP session. Closing a response does not guarantee that server-side work stops, and physical socket cleanup depends on the HTTP client.

PSR-18 requests and PSR-7 body reads can block. Cancellation and deadlines cannot interrupt those operations, including waiting for headers, reading a JSON body, or waiting for the next SSE chunk. They take effect only after control returns to the transport. A per-call deadline is therefore not a hard HTTP wall-clock limit. Configure network timeouts on the underlying HTTP client to bound blocking I/O.

This keeps HTTP transport compatible with PSR-18 clients and uses the existing SSE parser. It requires no framework-specific asynchronous client, at the cost of delayed cancellation during blocking I/O.