php-sdk/docs/client.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

23 KiB

Client

The MCP Client SDK provides a synchronous, framework-agnostic API for communicating with MCP servers from PHP applications. It handles connection management, request/response correlation, server-initiated requests (sampling), and real-time notifications.

Table of Contents

Overview

The client follows a builder pattern for configuration and provides a synchronous API for all operations:

use Mcp\Client;
use Mcp\Client\Transport\StdioTransport;

// Build and configure the client
$client = Client::builder()
    ->setClientInfo('My Client', '1.0.0')
    ->setInitTimeout(30)
    ->setRequestTimeout(120)
    ->build();

// Create a transport
$transport = new StdioTransport(
    command: 'php',
    args: ['/path/to/server.php'],
);

// Connect and use the server
$client->connect($transport);
$tools = $client->listTools();
$client->disconnect();

Client Builder

The Client\Builder provides fluent configuration of client instances.

Basic Configuration

use Mcp\Client;

$client = Client::builder()
    ->setClientInfo('My Application', '1.0.0', 'Description of my client')
    ->setInitTimeout(30)      // Seconds to wait for initialization
    ->setRequestTimeout(120)  // Seconds to wait for request responses
    ->setMaxRetries(3)        // Retry attempts for failed connections
    ->build();

Client Information

Set the client's identity reported to servers during initialization:

$client = Client::builder()
    ->setClientInfo(
        name: 'AI Assistant Client',
        version: '2.1.0',
        description: 'Client for automated AI workflows'
    )
    ->build();

Protocol Version

Specify the MCP protocol version to offer during the handshake (defaults to the latest):

use Mcp\Schema\Enum\ProtocolVersion;

$client = Client::builder()
    ->setProtocolVersion(ProtocolVersion::V2025_11_25)
    ->build();

This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as described in the specification's protocol version negotiation section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK cannot speak fails the handshake with a ConnectionException rather than continuing on a revision neither side agreed on. Use $client->getProtocolVersion() after connecting to read what was actually negotiated.

Modern revisions such as 2026-07-28 replaced initialize with per-request metadata, so they cannot be offered here. Configuring one still opens the handshake with ProtocolVersion::latestHandshake(), and the client logs a warning saying so.

See Protocol Version Negotiation for the server side of the exchange.

Capabilities

Declare client capabilities to enable server features:

use Mcp\Schema\ClientCapabilities;

$client = Client::builder()
    ->setCapabilities(new ClientCapabilities(
        sampling: true,  // Enable LLM sampling requests from server
        roots: true,     // Enable filesystem root listing
    ))
    ->build();

Notification Handlers

Register handlers for server-initiated notifications:

use Mcp\Client\Handler\Notification\LoggingNotificationHandler;
use Mcp\Schema\Notification\LoggingMessageNotification;

$loggingHandler = new LoggingNotificationHandler(
    static function (LoggingMessageNotification $notification) {
        echo "[{$notification->level->value}] {$notification->data}\n";
    }
);

$client = Client::builder()
    ->addNotificationHandler($loggingHandler)
    ->build();

Request Handlers

Register handlers for server-initiated requests (e.g., sampling):

use Mcp\Client\Handler\Request\SamplingRequestHandler;
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;

$samplingCallback = new class implements SamplingCallbackInterface {
    public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
    {
        // Perform LLM sampling and return result
    }
};

$client = Client::builder()
    ->addRequestHandler(new SamplingRequestHandler($samplingCallback))
    ->build();

Logger

Configure PSR-3 logging for debugging:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('mcp-client');
$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG));

$client = Client::builder()
    ->setLogger($logger)
    ->build();

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 environment variables
);

Parameters:

  • command (string): The command to execute
  • args (array): Command arguments
  • cwd (string|null): Working directory for the process
  • env (array|null): Environment variables
  • logger (LoggerInterface|null): Optional PSR-3 logger

HTTP Transport

Communicates with remote MCP servers over HTTP:

use Mcp\Client\Transport\HttpTransport;

$transport = new HttpTransport(
    endpoint: 'http://localhost:8000',
    headers: ['Authorization' => 'Bearer token'],
);

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

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
# Install any PSR-18 client - discovery works automatically
composer require php-http/guzzle7-adapter

Connecting to Servers

Establishing Connection

$client->connect($transport);

The connect() method performs the MCP initialization handshake:

  1. Opens the transport connection
  2. Sends InitializeRequest with client capabilities
  3. Waits for InitializeResult from server
  4. Sends InitializedNotification

Important

Always wrap connection in try/catch to handle ConnectionException for failed connections.

Checking Connection State

if ($client->isConnected()) {
    // Client is connected and initialized
}

Disconnecting

$client->disconnect();

Always disconnect when finished to clean up resources:

try {
    $client->connect($transport);
    // ... use the client ...
} finally {
    $client->disconnect();
}

Server Information

After successful connection, retrieve server metadata:

// Get server implementation info
$serverInfo = $client->getServerInfo();
echo "Server: {$serverInfo->name} v{$serverInfo->version}\n";

// Get server instructions
$instructions = $client->getInstructions();
if ($instructions) {
    echo "Instructions: {$instructions}\n";
}

Working with Tools

Listing Tools

$toolsResult = $client->listTools();

foreach ($toolsResult->tools as $tool) {
    echo "- {$tool->name}: {$tool->description}\n";
}

// Handle pagination
if ($toolsResult->nextCursor) {
    $moreTools = $client->listTools($toolsResult->nextCursor);
}

Calling Tools

$result = $client->callTool(
    name: 'calculate',
    arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'],
);

// Access results
foreach ($result->content as $content) {
    if ($content instanceof TextContent) {
        echo $content->text;
    }
}

Progress Notifications

Hook into tool execution progress (if server supports it):

$result = $client->callTool(
    name: 'long_running_task',
    arguments: ['data' => 'large_dataset'],
    onProgress: static function (float $progress, ?float $total, ?string $message) {
        $percent = $total > 0 ? round(($progress / $total) * 100) : 0;
        echo "Progress: {$percent}% - {$message}\n";
    }
);

Note

Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates.

Working with Resources

Listing Resources

$resourcesResult = $client->listResources();

foreach ($resourcesResult->resources as $resource) {
    echo "- {$resource->uri}: {$resource->name}\n";
}

Listing Resource Templates

$templatesResult = $client->listResourceTemplates();

foreach ($templatesResult->resourceTemplates as $template) {
    echo "- {$template->uriTemplate}: {$template->name}\n";
}

Reading Resources

$resourceResult = $client->readResource('config://app/settings');

foreach ($resourceResult->contents as $content) {
    if ($content instanceof TextResourceContents) {
        echo "Text: {$content->text}\n";
    } elseif ($content instanceof BlobResourceContents) {
        echo "Binary data (base64): {$content->blob}\n";
    }
}

Resources also support progress notifications:

$result = $client->readResource(
    uri: 'file://large-file.bin',
    onProgress: static function (float $progress, ?float $total, ?string $message) {
        echo "Reading: {$progress}/{$total} bytes\n";
    }
);

Working with Prompts

Listing Prompts

$promptsResult = $client->listPrompts();

foreach ($promptsResult->prompts as $prompt) {
    echo "- {$prompt->name}: {$prompt->description}\n";
}

Getting Prompts

$promptResult = $client->getPrompt(
    name: 'code_review',
    arguments: ['language' => 'php', 'code' => '...'],
);

foreach ($promptResult->messages as $message) {
    echo "{$message->role->value}: {$message->content->text}\n";
}

Prompts also support progress notifications:

$result = $client->getPrompt(
    name: 'generate_report',
    arguments: ['topic' => 'quarterly_analysis'],
    onProgress: static function (float $progress, ?float $total, ?string $message) {
        echo "Generating: {$message}\n";
    }
);

Requesting Completions

Request auto-completion suggestions for prompt or resource arguments:

use Mcp\Schema\PromptReference;

$completionResult = $client->complete(
    ref: new PromptReference('code_review'),
    argument: ['name' => 'language', 'value' => 'ph'],
);

foreach ($completionResult->values as $value) {
    echo "Suggestion: {$value}\n";
}

Server-Initiated Communication

The client can receive requests and notifications from the server when configured with appropriate handlers.

Logging Notifications

Receive structured log messages from the server:

use Mcp\Client\Handler\Notification\LoggingNotificationHandler;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Enum\LoggingLevel;

$loggingHandler = new LoggingNotificationHandler(
    static function (LoggingMessageNotification $notification) {
        // Route to your application's logging system
        $level = $notification->level;
        $message = $notification->data;
        
        match ($level) {
            LoggingLevel::Debug => logger()->debug($message),
            LoggingLevel::Info => logger()->info($message),
            LoggingLevel::Warning => logger()->warning($message),
            LoggingLevel::Error => logger()->error($message),
            default => logger()->info($message),
        };
    }
);

$client = Client::builder()
    ->addNotificationHandler($loggingHandler)
    ->build();

// Set minimum log level (optional)
$client->setLoggingLevel(LoggingLevel::Info);

Sampling (LLM Requests)

Handle server requests for LLM completions:

use Mcp\Client\Handler\Request\SamplingRequestHandler;
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
use Mcp\Exception\SamplingException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;

class LlmSamplingCallback implements SamplingCallbackInterface
{
    public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
    {
        try {
            // Call your LLM provider
            $response = $this->llmClient->complete(
                messages: $request->messages,
                maxTokens: $request->maxTokens,
                temperature: $request->temperature ?? 0.7,
            );
            
            return new CreateSamplingMessageResult(
                role: Role::Assistant,
                content: new TextContent($response->text),
                model: $response->model,
                stopReason: $response->stopReason,
            );
        } catch (\Throwable $e) {
            // Throw SamplingException to surface error to server
            throw new SamplingException(
                "LLM sampling failed: {$e->getMessage()}",
                (int) $e->getCode(),
                $e
            );
        }
    }
}

$client = Client::builder()
    ->setCapabilities(new ClientCapabilities(sampling: true))
    ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback))
    ->build();

Important

Error Handling in Sampling Callbacks:

When implementing sampling callbacks, error handling is critical:

  • Throw SamplingException to forward specific error messages to the server
  • Any other exception will be logged but return a generic error to the server

This distinction allows you to control what error information the server receives:

// Good: Server receives "Rate limit exceeded" message
throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.');

// Bad: Server receives generic "Error while sampling LLM" message
throw new \RuntimeException('Rate limit exceeded');

Elicitation (User Input Requests)

Handle server requests to elicit additional information from the user during tool execution. The server sends an elicitation/create request describing the fields it needs; your callback presents them to the user and returns an ElicitResult with one of three actions — accept (with the collected content), decline, or cancel:

use Mcp\Client\Handler\Request\ElicitationRequestHandler;
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
use Mcp\Exception\ElicitationException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;

class ConsoleElicitationCallback implements ElicitationCallbackInterface
{
    public function __invoke(ElicitRequest $request): ElicitResult
    {
        echo $request->message.\PHP_EOL;

        // Present $request->requestedSchema->properties to the user and collect input.
        $content = [];
        foreach ($request->requestedSchema->properties as $name => $definition) {
            $answer = readline($definition->title.': ');

            if (false === $answer) {
                // No input available — let the server know the user cancelled.
                return new ElicitResult(ElicitAction::Cancel);
            }

            $content[$name] = $answer;
        }

        return new ElicitResult(ElicitAction::Accept, $content);
    }
}

$client = Client::builder()
    ->setCapabilities(new ClientCapabilities(elicitation: true))
    ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback))
    ->build();

Return new ElicitResult(ElicitAction::Decline) when the user refuses to provide the information, and new ElicitResult(ElicitAction::Cancel) when they dismiss the request. Only the Accept action carries content.

Important

Error Handling in Elicitation Callbacks:

  • Throw ElicitationException to forward a specific error message to the server
  • Any other exception is logged but returns a generic error to the server
// Good: Server receives "No interactive console available" message
throw new ElicitationException('No interactive console available');

// Bad: Server receives generic "Error while processing elicitation" message
throw new \RuntimeException('No interactive console available');

See examples/client/stdio_elicitation.php for a runnable example against the elicitation demo server.

Roots

Roots let the client expose a list of file:// "workspace folders" that the server is allowed to operate on. Advertise the roots capability and register a handler that answers server roots/list requests:

use Mcp\Client\Handler\Request\ListRootsRequestHandler;
use Mcp\Client\Handler\Request\RootsCallbackInterface;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\ListRootsResult;
use Mcp\Schema\Root;

class WorkspaceRootsCallback implements RootsCallbackInterface
{
    public function __invoke(ListRootsRequest $request): ListRootsResult
    {
        return new ListRootsResult([
            new Root('file:///home/user/projects/app', 'Application'),
            new Root('file:///home/user/projects/library', 'Library'),
        ]);
    }
}

$client = Client::builder()
    ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true))
    ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback))
    ->build();

When the client's roots change, notify the server so it can request the updated list via roots/list. This requires advertising the roots.listChanged capability (rootsListChanged: true above); otherwise sendRootsListChanged() throws a RuntimeException. On a client that is not connected it throws a ConnectionException:

$client->sendRootsListChanged();

See examples/client/stdio_roots.php for a runnable example: it calls the inspect_workspace_roots tool of the client-communication demo server, which answers by issuing the roots/list request back to the client.

Error Handling

The client throws exceptions for various error conditions:

ConnectionException

Thrown when connection or initialization fails:

use Mcp\Exception\ConnectionException;

try {
    $client->connect($transport);
} catch (ConnectionException $e) {
    echo "Failed to connect: {$e->getMessage()}\n";
}

RequestException

Thrown when a request returns an error response:

use Mcp\Exception\RequestException;

try {
    $result = $client->callTool('unknown_tool', []);
} catch (RequestException $e) {
    echo "Request failed: {$e->getMessage()}\n";
    echo "Error code: {$e->getCode()}\n";
}

Complete Example

Here's a comprehensive example demonstrating client usage:

<?php

use Mcp\Client;
use Mcp\Client\Handler\Notification\LoggingNotificationHandler;
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
use Mcp\Client\Handler\Request\SamplingRequestHandler;
use Mcp\Client\Transport\StdioTransport;
use Mcp\Exception\SamplingException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;

// Configure logging notification handler
$loggingHandler = new LoggingNotificationHandler(
    static function (LoggingMessageNotification $notification) {
        echo "[LOG {$notification->level->value}] {$notification->data}\n";
    }
);

// Configure sampling callback
$samplingCallback = new class implements SamplingCallbackInterface {
    public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
    {
        echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n";
        
        try {
            // Integration with your LLM provider
            $response = "This is a mock LLM response for: " . 
                json_encode($request->messages);
            
            return new CreateSamplingMessageResult(
                role: Role::Assistant,
                content: new TextContent($response),
                model: 'mock-llm',
                stopReason: 'end_turn',
            );
        } catch (\Throwable $e) {
            throw new SamplingException(
                "Sampling failed: {$e->getMessage()}",
                0,
                $e
            );
        }
    }
};

// Build client
$client = Client::builder()
    ->setClientInfo('Example Client', '1.0.0')
    ->setInitTimeout(30)
    ->setRequestTimeout(120)
    ->setCapabilities(new ClientCapabilities(sampling: true))
    ->addNotificationHandler($loggingHandler)
    ->addRequestHandler(new SamplingRequestHandler($samplingCallback))
    ->build();

// Create transport
$transport = new StdioTransport(
    command: 'php',
    args: [__DIR__ . '/server.php'],
);

// Connect and use server
try {
    echo "Connecting to server...\n";
    $client->connect($transport);
    
    // Get server info
    $serverInfo = $client->getServerInfo();
    echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n";
    
    // List capabilities
    echo "Available tools:\n";
    $tools = $client->listTools();
    foreach ($tools->tools as $tool) {
        echo "  - {$tool->name}\n";
    }
    
    echo "\nAvailable resources:\n";
    $resources = $client->listResources();
    foreach ($resources->resources as $resource) {
        echo "  - {$resource->uri}\n";
    }
    
    // Set logging level
    $client->setLoggingLevel(LoggingLevel::Debug);
    
    // Call tool with progress
    echo "\nCalling tool with progress...\n";
    $result = $client->callTool(
        name: 'process_data',
        arguments: ['dataset' => 'large_file.csv'],
        onProgress: static function (float $progress, ?float $total, ?string $message) {
            $percent = $total > 0 ? round(($progress / $total) * 100) : 0;
            echo "  Progress: {$percent}% - {$message}\n";
        }
    );
    
    echo "\nResult:\n";
    foreach ($result->content as $content) {
        if ($content instanceof TextContent) {
            echo $content->text . "\n";
        }
    }
    
} catch (\Throwable $e) {
    echo "Error: {$e->getMessage()}\n";
    echo $e->getTraceAsString() . "\n";
} finally {
    $client->disconnect();
    echo "\nDisconnected.\n";
}