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
46 changes: 46 additions & 0 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,49 @@ The transport automatically discovers PSR-18 HTTP clients from:
# Install any PSR-18 client - discovery works automatically
composer require php-http/guzzle7-adapter
```

## Cancellation and deadlines

`callTool()` accepts optional `cancellation: ?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`. An observed
per-call deadline expiry throws `TimeoutException`.

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.
35 changes: 28 additions & 7 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
namespace Mcp;

use Mcp\Client\Builder;
use Mcp\Client\CancellationTokenInterface;
use Mcp\Client\Configuration;
use Mcp\Client\Protocol;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RequestCancelledException;
use Mcp\Exception\RequestException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Enum\LoggingLevel;
Expand Down Expand Up @@ -190,13 +193,31 @@ public function listTools(?string $cursor = null): ListToolsResult
/**
* Call a tool on the server.
*
* @param string $name Tool name
* @param array<string, mixed> $arguments Tool arguments
* Cancellation and deadlines are cooperative: on HTTP, blocking I/O has to
* return before the transport can observe the interruption. An answer that
* arrived for an interrupted call is discarded rather than returned.
*
* @param string $name Tool name
* @param array<string, mixed> $arguments Tool arguments
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
* Optional callback for progress updates
* Optional callback for progress updates
* @param CancellationTokenInterface|null $cancellation Non-blocking cancellation signal
* @param float|null $timeoutSeconds Finite positive timeout replacing the default for this call
*
* @throws RequestCancelledException When cancellation is observed
* @throws Exception\TimeoutException When the per-call deadline is observed to have expired
* @throws RequestException|ConnectionException|InvalidArgumentException|RuntimeException
*/
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null, ?CancellationTokenInterface $cancellation = null, ?float $timeoutSeconds = null): CallToolResult
{
if ($cancellation?->isCancellationRequested()) {
throw new RequestCancelledException('The client cancelled the request.');
}

if (null !== $timeoutSeconds && (!is_finite($timeoutSeconds) || $timeoutSeconds <= 0)) {
throw new InvalidArgumentException('The per-call timeout must be a finite positive number of seconds.');
}

$catalog = $this->protocol->getToolCatalog();

// A tool the listing showed to be malformed is refused here rather than
Expand All @@ -208,7 +229,7 @@ public function callTool(string $name, array $arguments = [], ?callable $onProgr

$request = new CallToolRequest($name, $arguments);

$response = $this->sendRequest($request, $onProgress);
$response = $this->sendRequest($request, $onProgress, $cancellation, $timeoutSeconds);

return CallToolResult::fromArray($response->result);
}
Expand Down Expand Up @@ -337,14 +358,14 @@ public function sendRootsListChanged(): void
*
* @throws RequestException|ConnectionException
*/
private function sendRequest(Request $request, ?callable $onProgress = null): Response
private function sendRequest(Request $request, ?callable $onProgress = null, ?CancellationTokenInterface $cancellation = null, ?float $timeoutSeconds = null): Response
{
if (!$this->isConnected()) {
throw new ConnectionException('Client is not connected. Call connect() first.');
}

$withProgress = null !== $onProgress;
$fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress));
$fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress, $cancellation, $timeoutSeconds));
$response = $this->transport->runRequest($fiber, $onProgress);

if ($response instanceof Error) {
Expand Down
20 changes: 20 additions & 0 deletions src/Client/CancellationTokenInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?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\Client;

/**
* Polled while a request waits for its response.
*/
interface CancellationTokenInterface
{
public function isCancellationRequested(): bool;
}
82 changes: 77 additions & 5 deletions src/Client/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@
use Mcp\Client\Stateless\RequestEnvelope;
use Mcp\Client\Stateless\ToolCatalog;
use Mcp\Client\Transport\HeaderAwareTransportInterface;
use Mcp\Client\Transport\HttpTransport;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\RequestCancelledException;
use Mcp\Exception\TimeoutException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Notification\CancelledNotification;
use Mcp\Schema\Notification\InitializedNotification;
use Mcp\Schema\Request\DiscoverRequest;
use Mcp\Schema\Request\InitializeRequest;
Expand Down Expand Up @@ -364,8 +368,9 @@ private function reconcileVersion(mixed $supportedVersions): void
*
* @return Response<array<string, mixed>>|Error
*/
public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error
public function request(Request $request, int $timeout, bool $withProgress = false, ?CancellationTokenInterface $cancellation = null, ?float $callTimeout = null): Response|Error
{
$deadline = null !== $callTimeout ? microtime(true) + $callTimeout : null;
$payload = $request->withId(0)->jsonSerialize();
unset($payload['id']);

Expand All @@ -374,11 +379,11 @@ public function request(Request $request, int $timeout, bool $withProgress = fal
}

if (null === $this->envelope) {
return $this->exchange($payload, $timeout);
return $this->exchange($payload, $timeout, $cancellation, $deadline);
}

for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) {
$response = $this->exchange($payload, $timeout);
$response = $this->exchange($payload, $timeout, $cancellation, $deadline);

if ($response instanceof Error) {
$retry = $this->withAcceptedVersion($response);
Expand Down Expand Up @@ -477,8 +482,12 @@ private function withAcceptedVersion(Error $error): ?ProtocolVersion
*
* @return Response<array<string, mixed>>|Error
*/
private function exchange(array $payload, int $timeout): Response|Error
private function exchange(array $payload, int $timeout, ?CancellationTokenInterface $cancellation = null, ?float $deadline = null): Response|Error
{
if (null !== ($interruption = self::interruption($cancellation, $deadline))) {
throw $interruption;
}

$requestId = $this->state->nextRequestId();
$payload['id'] = $requestId;

Expand All @@ -487,6 +496,12 @@ private function exchange(array $payload, int $timeout): Response|Error
try {
$this->send($payload, 'request');

// send() can block and leave a JSON answer already buffered: drop it
// and report the interruption instead of a success nobody awaits.
if (null !== ($interruption = self::interruption($cancellation, $deadline))) {
throw $interruption;
}

$immediate = $this->state->consumeResponse($requestId);
if (null !== $immediate) {
$this->logger->debug('Received immediate response', ['id' => $requestId]);
Expand All @@ -496,18 +511,69 @@ private function exchange(array $payload, int $timeout): Response|Error

$this->logger->debug('Suspending fiber for response', ['id' => $requestId]);

return \Fiber::suspend([
$response = \Fiber::suspend([
'type' => 'await_response',
'request_id' => $requestId,
'timeout' => $timeout,
'cancellation' => $cancellation,
'deadline' => $deadline,
]);

// A transport may resume with a buffered reply before checking interruption.
if (null !== ($interruption = self::interruption($cancellation, $deadline))) {
throw $interruption;
}

return $response;
} catch (RequestCancelledException|TimeoutException $e) {
$this->state->consumeResponse($requestId);
$this->notifyCancellation($requestId, $e->getMessage());

throw $e;
} finally {
// Only the response path clears it, so a request that timed out or
// whose send() threw would stay pending and fail every later one.
$this->state->removePendingRequest($requestId);
}
}

/**
* The interruption an in-flight request is subject to, if any. Checked on
* both sides of a send and after the transport resumes a suspended request.
*
* @phpstan-impure
*/
private static function interruption(?CancellationTokenInterface $cancellation, ?float $deadline): RequestCancelledException|TimeoutException|null
{
if ($cancellation?->isCancellationRequested()) {
return new RequestCancelledException('The client cancelled the request.');
}

if (null !== $deadline && microtime(true) >= $deadline) {
return new TimeoutException('The request deadline expired.');
}

return null;
}

/**
* Tell the server an abandoned request's result will go unused. Only stdio and
* handshake-era HTTP need it: a modern connection signals by closing the
* response stream. Best effort — a send failure is logged, not raised.
*/
private function notifyCancellation(int $requestId, string $reason): void
{
if ($this->transport instanceof HttpTransport && true === $this->state->getProtocolVersion()?->isModern()) {
return;
}

try {
$this->sendNotification(new CancelledNotification($requestId, $reason));
} catch (\Throwable $notificationError) {
$this->logger->warning('Could not send request cancellation notification.', ['request_id' => $requestId, 'exception' => $notificationError]);
}
}

/**
* Send a notification to the server (fire and forget).
*/
Expand Down Expand Up @@ -611,6 +677,12 @@ private function handleResponse(Response|Error $response): void
return;
}

if (!\array_key_exists($requestId, $this->state->getPendingRequests())) {
$this->logger->debug('Ignoring response for a request that is no longer pending.', ['id' => $requestId]);

return;
}

$this->logger->debug('Handling response', ['id' => $requestId]);

$this->state->storeResponse($requestId, $response->jsonSerialize());
Expand Down
Loading
Loading