php-sdk/docs/client.md
Johannes Wachter bdc9d86671
[Client][Server] Add Roots support (#395)
* [Client][Server] Add Roots support

Wire up the MCP "roots" capability on both sides:

Client (php-sdk as client):
- RootsCallbackInterface + ListRootsRequestHandler answer server roots/list
  requests, mirroring the Sampling handler.
- Client::sendRootsListChanged() emits notifications/roots/list_changed.

Server (php-sdk as server):
- ClientGateway::listRoots() requests the client's roots, and
  supportsRoots() reports whether the client advertised the capability,
  normalizing the stored capabilities to an array so an empty
  capabilities object (\stdClass) doesn't cause a TypeError.
- Add ListRootsResult::fromArray() (used by listRoots()).
- ListRootsRequestHandler forwards RootsException messages to the server
  (new Mcp\Exception\RootsException), mirroring SamplingRequestHandler.
- Client::sendRootsListChanged() throws when the client did not advertise
  the roots.listChanged capability.

Examples, docs and tests:
- examples/README/docs advertise
  `new ClientCapabilities(roots: true, rootsListChanged: true)` before
  sending notifications/roots/list_changed.
- CHANGELOG entry for Roots (unreleased 0.7.0 section at the time).
- Unit tests for capability serialization/round-trip, sendRootsListChanged
  gating, and RootsException message forwarding.

phpunit, php-cs-fixer and phpstan green.

* [Client][Server] Address review: schema validation, changelog section, connection guard, working example

Responds to chr-hertel's review of the Roots support:

- ListRootsResult::fromArray() rejects non-array root elements instead of
  accepting malformed entries silently.
- CHANGELOG entry moved to the next unreleased section as the release
  train advanced past it (0.7.0 -> 0.8.0).
- Client::sendRootsListChanged() went straight to
  Protocol::sendNotification(), which sends via
  `$this->transport?->send()` -- on a client that is not connected the
  notification was silently dropped. It now throws a ConnectionException
  like sendRequest() does.
- The stdio example only advertised the roots capability, so the
  roots/list handler never actually ran. The client-communication demo
  server gets an `inspect_workspace_roots` tool that calls
  ClientGateway::supportsRoots()/listRoots(); the example calls it, so
  the server actually issues roots/list and the client answers it.
  Dropped the try/catch wrapper from the example.
- ClientTest's connected-client mock now goes through setState() so
  isConnected() reflects a real handshake instead of a stubbed flag.

phpunit, php-cs-fixer and phpstan green.
2026-08-14 23:04:25 +02:00

22 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 (defaults to latest):

use Mcp\Schema\Enum\ProtocolVersion;

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

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";
}